From 66dccb0b1220e0b6a30bae0e0cb16ca5ba4354f0 Mon Sep 17 00:00:00 2001 From: Mikhail Fedosov Date: Tue, 28 Oct 2025 14:31:43 +0400 Subject: [PATCH 1/3] refactor: extract duplicate code, add TDD test coverage, standardize on pnpm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract duplicate code into centralized utilities: - decimal-converter.js: P3D decimal multiplier handling (44 lines removed from BridgeForm) - error-parser.js: transaction error categorization (177 lines removed from Expatriation/Repatriation) - network-switcher.js: MetaMask network switching (111 lines removed from BridgeForm/AssistantsList) Add comprehensive test coverage using TDD methodology: - 27 tests for decimal-converter - 32 tests for error-parser - 16 tests for network-switcher - All tests passing (88/88) Fix pre-existing test failures: - Export ProviderManager class for settings-consistency tests - Correct health status expectation in retry-with-fallback tests Standardize on pnpm package manager: - Add .mise.toml for Node 18 + pnpm - Add .npmrc with hoisting config for React compatibility - Update all docs (README, CLAUDE.md) to use pnpm commands - Add Makefile with test target - Add packageManager field to package.json - Resolve ESLint plugin conflict with .eslintrc.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .eslintrc.json | 7 + .gitignore | 3 + .mise.toml | 3 + .npmrc | 11 + CLAUDE.md | 267 +++++++++++++++ Makefile | 3 + README.md | 16 +- package.json | 7 +- src/components/AssistantsList.js | 52 +-- src/components/BridgeForm.js | 118 +------ src/components/Expatriation.js | 116 +------ src/components/Repatriation.js | 82 +---- .../docs/COUNTERSTAKE_BRIDGE_DEPLOY.md | 4 +- src/utils/__tests__/decimal-converter.test.js | 192 +++++++++++ src/utils/__tests__/error-parser.test.js | 309 ++++++++++++++++++ src/utils/__tests__/network-switcher.test.js | 248 ++++++++++++++ .../__tests__/retry-with-fallback.test.js | 6 +- src/utils/decimal-converter.js | 39 +++ src/utils/error-parser.js | 104 ++++++ src/utils/network-switcher.js | 46 +++ src/utils/provider-manager.js | 3 + 21 files changed, 1277 insertions(+), 359 deletions(-) create mode 100644 .eslintrc.json create mode 100644 .mise.toml create mode 100644 .npmrc create mode 100644 CLAUDE.md create mode 100644 Makefile create mode 100644 src/utils/__tests__/decimal-converter.test.js create mode 100644 src/utils/__tests__/error-parser.test.js create mode 100644 src/utils/__tests__/network-switcher.test.js create mode 100644 src/utils/decimal-converter.js create mode 100644 src/utils/error-parser.js create mode 100644 src/utils/network-switcher.js diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..a48ce4f --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "root": true, + "extends": [ + "react-app", + "react-app/jest" + ] +} diff --git a/.gitignore b/.gitignore index c8c4a5b..dc53cee 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,9 @@ jspm_packages/ # Optional npm cache directory .npm +# npm lock file (use pnpm-lock.yaml instead) +package-lock.json + # Optional eslint cache .eslintcache diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..81e9db9 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,3 @@ +[tools] +node = "18" +pnpm = "latest" diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..a09db08 --- /dev/null +++ b/.npmrc @@ -0,0 +1,11 @@ +# Use pnpm's strict peer dependencies +strict-peer-dependencies=false + +# Auto-install peers +auto-install-peers=true + +# Shamefully hoist (for React compatibility) +shamefully-hoist=true + +# Use node-linker for better compatibility with tools expecting node_modules +node-linker=hoisted diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cb5bd01 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,267 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Counterstake Bridge Frontend - A React.js Web3 application enabling cross-chain token transfers between Ethereum, BSC, and 3DPass networks using a trustless counterstake mechanism. + +## Development Commands + +### Running the Application +```bash +pnpm start # Start development server (default port 3000) +pnpm dev # Alias for pnpm start +pnpm build # Build production bundle +pnpm test # Run tests in watch mode +``` + +### Code Quality +```bash +pnpm lint # Run ESLint +pnpm lint:fix # Auto-fix ESLint issues +``` + +### Testing Individual Files +```bash +pnpm test -- src/utils/__tests__/retry-with-fallback.test.js +pnpm test -- --testNamePattern="test name pattern" +``` + +## Architecture Overview + +### Network Configuration System + +All network, bridge, and token configurations are centralized in `src/config/networks.js`. This file defines: + +- **Network configurations** for Ethereum, BSC, and 3DPass +- **Bridge instances** (export, import, import_wrapper types) +- **Token configurations** including precompile addresses +- **Oracle addresses** for price feeds +- **Assistant contracts** (pooled liquidity providers) + +**Critical**: When adding new bridges, follow the exact procedure documented at the top of `src/config/networks.js` (lines 1-17). + +### 3DPass Precompile System + +3DPass uses a unique ERC20 precompile system where native tokens (like P3D) are accessed through special precompile addresses rather than standard ERC20 contracts: + +- `P3D_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000000802` (native P3D) +- `wUSDT = 0xfBFBfbFA000000000000000000000000000000de` (wrapped USDT) + +All tokens on 3DPass, including the native token, are treated as ERC20 tokens via precompiles. See `src/utils/threedpass.js` for precompile handling logic. + +### Bridge Types + +Three distinct bridge contract types exist: + +1. **Export bridges** (`src/contracts/abi.js:EXPORT_ABI`) - Lock tokens on source chain, emit expatriation events +2. **Import bridges** (`IMPORT_ABI`) - Mint/burn wrapped tokens on EVM chains (Ethereum, BSC) +3. **Import Wrapper bridges** (`IMPORT_WRAPPER_ABI`) - Wrap existing precompile tokens on 3DPass + +Bridge type detection is handled in `src/utils/bridge-detector.js`. + +### Context Architecture + +Two main React contexts manage global state: + +1. **Web3Context** (`src/contexts/Web3Context.js`) + - Wallet connection (MetaMask-only) + - Network detection and switching + - Provider/signer management + - Custom RPC URL support via settings + +2. **SettingsContext** (referenced in Web3Context) + - Custom contract addresses + - Custom tokens + - RPC URL overrides + - Persisted in localStorage + +### Component Structure + +``` +src/ +├── components/ +│ ├── BridgeForm.js # Main transfer interface +│ ├── ClaimList.js # Transfer history and claims +│ ├── AssistantsList.js # Pooled liquidity UI +│ ├── CreateNewBridge.js # Bridge deployment +│ ├── CreateNewAssistant.js # Assistant deployment +│ ├── DeployNewOracle.js # Oracle deployment +│ └── Header.js # Wallet connection & navigation +├── utils/ +│ ├── bridge-detector.js # Auto-detect bridge type +│ ├── bridge-contracts.js # Bridge interaction helpers +│ ├── threedpass.js # 3DPass precompile utilities +│ ├── token-detector.js # Auto-detect token type +│ ├── assistant-detector.js # Assistant type detection +│ ├── provider-manager.js # Multi-provider with fallbacks +│ ├── retry-with-fallback.js # Resilient RPC calls +│ ├── fetch-claims.js # Claim event fetching +│ ├── fetch-last-transfers.js # Transfer event fetching +│ └── claim-estimator.js # Time estimation for claims +└── config/ + └── networks.js # All network/bridge/token config +``` + +### Key Architectural Patterns + +**Provider Management**: `provider-manager.js` implements multi-provider support with automatic fallback. When a provider fails, it rotates to the next available RPC URL. + +**Event Caching**: `event-cache.js` caches blockchain events (claims, transfers) to reduce RPC calls. Cache invalidation happens on block number changes. + +**Settings Integration**: Settings from localStorage can override default network configs (RPC URLs, contract addresses, tokens). See `src/utils/settings.js`. + +**Decimal Handling**: P3D has special decimal handling - native P3D uses 12 decimals on-chain but 18 decimals in EVM representation. Use `decimalsDisplayMultiplier: 1000000` in token configs to compensate. + +## Critical Implementation Details + +### Bridge Instance Creation Flow + +1. Deploy Oracle on both chains +2. Add home token to `tokens` config +3. Set initial Oracle prices (Token/_NATIVE_, token_symbol/_NATIVE_, _NATIVE_/token_symbol) +4. Create Import bridge instance using Oracle address +5. Add foreign token to `tokens` config +6. Add Import bridge to `bridges` config +7. Create Export bridge instance using Import bridge foreign token address +8. Add Export bridge to `bridges` config + +### Assistant Types + +- **Export Assistants**: Provide liquidity for export bridges (lock operations) +- **Import Assistants**: Provide liquidity for import bridges (mint/burn operations) +- **Import Wrapper Assistants**: Provide liquidity for import_wrapper bridges (3DPass-specific) + +Each assistant issues ERC20 shares representing pool ownership. + +### Stake Token vs Transfer Token + +Bridges require stake in a designated token (often different from transfer token): +- Ethereum P3D Import: stake in ETH, transfer P3D +- 3DPass USDT Import Wrapper: stake in P3D, transfer wUSDT +- Ethereum USDT Export: stake in USDT, transfer USDT + +Use `getRequiredStake()` from bridge contracts to calculate stake amounts. + +### Network Detection + +`Web3Context.getCurrentNetwork()` returns the active network. It prioritizes: +1. Context network (manually selected) +2. Detected network from provider +3. Custom settings-based network configuration + +When MetaMask changes networks, the context automatically updates provider, signer, and network state without page reload. + +## Testing Notes + +Tests exist in `src/utils/__tests__/`: +- `retry-with-fallback.test.js` - Provider fallback logic +- `settings-consistency.test.js` - Settings validation + +Run tests with `pnpm test` for watch mode. + +## Test-Driven Development (TDD) + +**MANDATORY for all code changes, features, and bug fixes.** + +### Iron Law +``` +NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST +``` + +### RED-GREEN-REFACTOR Cycle + +1. **RED**: Write a failing test + - Write test first showing desired behavior + - Run test and verify it fails for the right reason + - If test passes immediately, you're testing existing code (fix test) + +2. **GREEN**: Write minimal code to pass + - Implement simplest code to make test pass + - No extra features, no premature optimization + - Run test and verify it passes + +3. **REFACTOR**: Clean up + - Remove duplication + - Improve names + - Keep tests green + +4. **Repeat**: Next test for next behavior + +### Non-Negotiable Rules + +- Write code before test? **Delete it. Start over.** +- Test passes immediately? **Fix test or remove it.** +- "Skip TDD just this once"? **No. That's rationalization.** +- Already manually tested? **Still need automated tests.** +- Tests after achieve same goals? **No. Tests-first prove they work.** + +### Test Requirements for Commits + +``` +NO COMMITS ALLOWED UNLESS 100% OF TESTS PASS +``` + +Before any commit or pull request: +1. Run full test suite: `pnpm test -- --no-watch --passWithNoTests --watchAll=false` +2. **ALL tests must pass** - no exceptions +3. If any test fails, fix it before committing +4. Never commit with failing, skipped, or disabled tests + +**Work is not complete until all tests pass.** Test failures indicate either: +- Code is broken (fix the code) +- Test expectations are wrong (fix the test) +- Edge case discovered (add proper handling) + +### Exceptions + +Only skip TDD with explicit permission for: +- Throwaway prototypes +- Generated code +- Configuration files + +**Everything else requires TDD. No exceptions.** + +## Styling + +Uses Tailwind CSS with custom theme in `tailwind.config.js`: +- Custom color palette (primary, secondary, accent, success, warning, error, dark) +- Custom animations (fade-in, slide-up, pulse-slow) +- Dark theme optimized (background: `bg-dark-950`) + +## Security Considerations + +- **MetaMask-only**: Only MetaMask wallet is supported +- **Address validation**: All addresses validated via `ethers.utils.isAddress()` +- **Network validation**: All network switches go through MetaMask confirmation +- **No auto-connect**: Users must manually connect wallet (auto-connect disabled in Web3Context) + +## Common Development Patterns + +**Reading bridge settings**: +```javascript +const settings = await bridgeContract.settings(); +// Returns: { tokenAddress, ratio100, counterstake_coef100, min_tx_age, min_stake, large_threshold } +``` + +**Detecting bridge type**: +```javascript +import { detectBridgeType } from './utils/bridge-detector'; +const bridgeType = await detectBridgeType(provider, bridgeAddress); +// Returns: 'export' | 'import' | 'import_wrapper' +``` + +**Working with 3DPass precompiles**: +```javascript +import { get3DPassTokenByAddress, get3DPassTokenABI } from './utils/threedpass'; +const tokenConfig = get3DPassTokenByAddress(tokenAddress); +const abi = get3DPassTokenABI(tokenAddress); +``` + +**Multi-provider with fallback**: +```javascript +import { getProvider } from './utils/provider-manager'; +const provider = await getProvider('ETHEREUM', settings); +// Automatically handles fallback if primary RPC fails +``` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e8232bb --- /dev/null +++ b/Makefile @@ -0,0 +1,3 @@ +.PHONY: test +test: + pnpm test -- --no-watch --passWithNoTests --watchAll=false diff --git a/README.md b/README.md index 8530a11..afe46ef 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ A modern React.js Web3 frontend for the Counterstake Bridge, enabling cross-chai ## Prerequisites -- Node.js 16+ and pnpm/npm/yarn +- Node.js 16+ and pnpm - MetaMask browser extension - Access to supported networks in MetaMask @@ -44,14 +44,7 @@ A modern React.js Web3 frontend for the Counterstake Bridge, enabling cross-chai 2. **Install dependencies** ```bash - # Using pnpm (recommended) pnpm install - - # Or using npm - npm install - - # Or using yarn - yarn install ``` 3. **Configure networks** (Optional) @@ -59,16 +52,9 @@ A modern React.js Web3 frontend for the Counterstake Bridge, enabling cross-chai 4. **Start the development server** ```bash - # Using pnpm pnpm dev # or pnpm start - - # Or using npm - npm start - - # Or using yarn - yarn start ``` 5. **Open your browser** diff --git a/package.json b/package.json index 5be1adf..fac86c2 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "Web3 frontend for Counterstake Bridge - Cross-chain transfers between ETH, BSC, and 3DPass", "private": true, + "packageManager": "pnpm@9.0.0", "dependencies": { "@openzeppelin/contracts": "4.8.3", "@testing-library/jest-dom": "^5.16.4", @@ -36,12 +37,6 @@ "lint": "eslint src --ext .js,.jsx,.ts,.tsx", "lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix" }, - "eslintConfig": { - "extends": [ - "react-app", - "react-app/jest" - ] - }, "browserslist": { "production": [ ">0.2%", diff --git a/src/components/AssistantsList.js b/src/components/AssistantsList.js index b0f2299..1f9347e 100644 --- a/src/components/AssistantsList.js +++ b/src/components/AssistantsList.js @@ -10,6 +10,7 @@ import WithdrawManagementFee from './WithdrawManagementFee'; import WithdrawSuccessFee from './WithdrawSuccessFee'; import AssignNewManager from './AssignNewManager'; import { IPRECOMPILE_ERC20_ABI } from '../contracts/abi'; +import { switchNetwork } from '../utils/network-switcher'; const AssistantsList = () => { const { getAssistantContractsWithSettings, getAllNetworksWithSettings, get3DPassTokenDecimalsDisplayMultiplier } = useSettings(); @@ -939,48 +940,17 @@ const AssistantsList = () => { }, []); const switchToRequiredNetwork = useCallback(async (requiredNetwork) => { - try { - console.log('🔄 Switching to network:', requiredNetwork.name, 'Chain ID:', requiredNetwork.chainId); - - const chainIdHex = `0x${requiredNetwork.chainId.toString(16)}`; - - try { - await window.ethereum.request({ - method: 'wallet_switchEthereumChain', - params: [{ chainId: chainIdHex }], - }); - console.log('✅ Network switched successfully'); - return true; - } catch (switchError) { - console.log('⚠️ Network not added, attempting to add it...'); - - if (switchError.code === 4902) { - try { - await window.ethereum.request({ - method: 'wallet_addEthereumChain', - params: [{ - chainId: chainIdHex, - chainName: requiredNetwork.name, - nativeCurrency: requiredNetwork.nativeCurrency, - rpcUrls: [requiredNetwork.rpcUrl], - blockExplorerUrls: [requiredNetwork.explorer], - }], - }); - console.log('✅ Network added and switched successfully'); - return true; - } catch (addError) { - console.error('❌ Failed to add network:', addError); - return false; - } - } else { - console.error('❌ Failed to switch network:', switchError); - return false; - } - } - } catch (error) { - console.error('❌ Network switching error:', error); - return false; + console.log('🔄 Switching to network:', requiredNetwork.name, 'Chain ID:', requiredNetwork.chainId); + + const success = await switchNetwork(requiredNetwork); + + if (success) { + console.log('✅ Network switched successfully'); + } else { + console.error('❌ Network switching failed'); } + + return success; }, []); const handleDeposit = useCallback(async (assistant) => { diff --git a/src/components/BridgeForm.js b/src/components/BridgeForm.js index a1aed46..96802ff 100644 --- a/src/components/BridgeForm.js +++ b/src/components/BridgeForm.js @@ -5,6 +5,8 @@ import { NETWORKS } from '../config/networks'; import { getTokenBalance, isValidAddress, isValidAmount } from '../utils/web3'; import { transferToForeignChain, createBridgeContract } from '../utils/bridge-contracts'; import { getMaxSafeReward } from '../utils/safe-reward-handler'; +import { convertActualToDisplay, convertDisplayToActual } from '../utils/decimal-converter'; +import { switchNetwork } from '../utils/network-switcher'; import { ArrowDown, ArrowRightLeft, Eye, EyeOff } from 'lucide-react'; import { motion } from 'framer-motion'; import Expatriation from './Expatriation'; @@ -26,53 +28,6 @@ const compareBalances = (amount, balance, tolerance = 0.000001) => { return numAmount <= numBalance + tolerance; }; -// Convert from actual amount (from contract) to display amount (with multiplier) -const convertActualToDisplay = (actualAmount, decimals, tokenAddress, getTokenDecimalsDisplayMultiplier) => { - try { - if (!actualAmount || parseFloat(actualAmount) === 0) return '0'; - - const num = parseFloat(actualAmount); - - // Check if this is a P3D token and apply the multiplier - if (tokenAddress) { - const decimalsDisplayMultiplier = getTokenDecimalsDisplayMultiplier(tokenAddress); - if (decimalsDisplayMultiplier) { - // Apply the multiplier: 0.000001 * 1000000 = 1.0 - const displayNumber = num * decimalsDisplayMultiplier; - return displayNumber.toFixed(6).replace(/\.?0+$/, '') || '0'; - } - } - - return actualAmount; - } catch (error) { - return '0'; - } -}; - -// Convert from display amount (with multiplier) to actual amount (for contract) -const convertDisplayToActual = (displayAmount, decimals, tokenAddress, getTokenDecimalsDisplayMultiplier) => { - try { - if (!displayAmount || parseFloat(displayAmount) === 0) return '0'; - - const num = parseFloat(displayAmount); - - // Check if this is a P3D token and remove the multiplier - if (tokenAddress) { - const decimalsDisplayMultiplier = getTokenDecimalsDisplayMultiplier(tokenAddress); - if (decimalsDisplayMultiplier) { - // Remove the multiplier: 1.0 / 1000000 = 0.000001 - const actualNumber = num / decimalsDisplayMultiplier; - // Format to the correct number of decimal places to avoid precision issues - return actualNumber.toFixed(decimals); - } - } - - return displayAmount; - } catch (error) { - return '0'; - } -}; - const BridgeForm = ({ onNavigateToTransfers }) => { const { account, provider, signer, network, isConnected } = useWeb3(); const { getNetworkWithSettings, getBridgeInstancesWithSettings, getTokenDecimalsDisplayMultiplier } = useSettings(); @@ -542,70 +497,29 @@ const BridgeForm = ({ onNavigateToTransfers }) => { // Switch to selected network const switchToNetwork = async (networkName) => { - if (!window.ethereum) { - console.error('MetaMask not available'); + const networkKey = Object.keys(NETWORKS).find(key => NETWORKS[key].name === networkName); + if (!networkKey) { + console.error('Network not found:', networkName); + toast.error(`Network ${networkName} not found`); return false; } + const networkConfig = NETWORKS[networkKey]; setIsSwitchingNetwork(true); - - try { - // Find the network configuration - const networkKey = Object.keys(NETWORKS).find(key => NETWORKS[key].name === networkName); - if (!networkKey) { - console.error('Network not found:', networkName); - return false; - } - - const networkConfig = NETWORKS[networkKey]; - const chainId = `0x${networkConfig.id.toString(16)}`; - - console.log('🔄 Switching to network:', { networkName, chainId }); - await window.ethereum.request({ - method: 'wallet_switchEthereumChain', - params: [{ chainId }], - }); + try { + console.log('🔄 Switching to network:', networkName); + const success = await switchNetwork(networkConfig); - console.log('✅ Network switched successfully to:', networkName); - toast.success(`Switched to ${networkName} network`); - return true; - } catch (error) { - console.error('❌ Network switch failed:', error); - - // If the network is not added to MetaMask, try to add it - if (error.code === 4902) { - try { - const networkKey = Object.keys(NETWORKS).find(key => NETWORKS[key].name === networkName); - const networkConfig = NETWORKS[networkKey]; - - await window.ethereum.request({ - method: 'wallet_addEthereumChain', - params: [{ - chainId: `0x${networkConfig.id.toString(16)}`, - chainName: networkConfig.name, - nativeCurrency: networkConfig.nativeCurrency, - rpcUrls: [networkConfig.rpcUrl], - blockExplorerUrls: [networkConfig.explorer], - }], - }); - - console.log('✅ Network added and switched successfully to:', networkName); - toast.success(`Added and switched to ${networkName} network`); - return true; - } catch (addError) { - console.error('❌ Failed to add network:', addError); - toast.error(`Failed to add ${networkName} network to MetaMask`); - return false; - } - } else if (error.code === 4001) { - // User rejected the request - toast.error('Network switch cancelled by user'); - return false; + if (success) { + console.log('✅ Network switched successfully to:', networkName); + toast.success(`Switched to ${networkName} network`); } else { + console.error('❌ Network switch failed'); toast.error(`Failed to switch to ${networkName} network`); - return false; } + + return success; } finally { setIsSwitchingNetwork(false); } diff --git a/src/components/Expatriation.js b/src/components/Expatriation.js index 39cf22b..6b3c4f9 100644 --- a/src/components/Expatriation.js +++ b/src/components/Expatriation.js @@ -6,6 +6,7 @@ import toast from 'react-hot-toast'; import { parseAndValidateReward } from '../utils/safe-reward-handler'; import { addTransferEventToStorage } from './ClaimList'; import { getBlockTimestamp } from '../utils/bridge-contracts'; +import { parseTransactionError } from '../utils/error-parser'; // Safely convert to EIP-55 checksum if it's an EVM address const toChecksumAddress = (address) => { @@ -40,111 +41,6 @@ const Expatriation = ({ const [useMaxAllowance, setUseMaxAllowance] = useState(false); const [isRevoking, setIsRevoking] = useState(false); - // Helper function to parse and categorize errors - const parseError = (error) => { - const errorMessage = error.message || error.toString(); - - // User rejection/cancellation - if (errorMessage.includes('user rejected') || - errorMessage.includes('ACTION_REJECTED') || - errorMessage.includes('User denied') || - errorMessage.includes('cancelled') || - error.code === 'ACTION_REJECTED') { - return { - type: 'user_rejection', - title: 'Transaction Cancelled', - message: 'You cancelled the transaction. No changes were made.', - canRetry: true, - isUserError: true - }; - } - - // Transaction replaced/repriced (user adjusted gas) - if (errorMessage.includes('transaction was replaced') || - error.code === 'TRANSACTION_REPLACED') { - return { - 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 - }; - } - - // Transaction hash issues (specific to your problem) - if (errorMessage.includes('Transaction does not have a transaction hash') || - errorMessage.includes('there was a problem') || - error.code === -32603) { - return { - type: 'transaction_hash_error', - title: 'Transaction Submission Failed', - message: 'The transaction could not be submitted properly. This often happens with allowance increases.', - canRetry: true, - isUserError: false - }; - } - - // Insufficient funds - if (errorMessage.includes('insufficient funds') || - errorMessage.includes('insufficient balance')) { - return { - type: 'insufficient_funds', - title: 'Insufficient Funds', - message: 'You don\'t have enough tokens or ETH to complete this transaction.', - canRetry: false, - isUserError: true - }; - } - - // Gas estimation failed - if (errorMessage.includes('gas required exceeds allowance') || - errorMessage.includes('gas estimation failed')) { - return { - type: 'gas_error', - title: 'Gas Estimation Failed', - message: 'The transaction requires more gas than available. Try increasing gas limit.', - canRetry: true, - isUserError: false - }; - } - - // Network issues - if (errorMessage.includes('network') || - errorMessage.includes('timeout') || - errorMessage.includes('connection')) { - return { - type: 'network_error', - title: 'Network Error', - message: 'There was a network issue. Please check your connection and try again.', - canRetry: true, - isUserError: false - }; - } - - // Contract/transaction errors - if (errorMessage.includes('execution reverted') || - errorMessage.includes('revert')) { - return { - type: 'contract_error', - title: 'Transaction Failed', - message: 'The transaction was rejected by the smart contract. Please check your inputs.', - canRetry: true, - isUserError: false - }; - } - - // Default error - return { - type: 'unknown', - title: 'Operation Failed', - message: errorMessage, - canRetry: true, - isUserError: false - }; - }; - - // Create token contract for approval const createTokenContract = useCallback((tokenAddress) => { const tokenABI = [ @@ -234,7 +130,7 @@ const Expatriation = ({ return needsApproval; } catch (error) { console.error('Error checking approval:', error); - const errorInfo = parseError(error); + const errorInfo = parseTransactionError(error); // Show toast notification toast.error( @@ -399,7 +295,7 @@ const Expatriation = ({ } catch (error) { console.error('❌ Approval failed:', error); - const errorInfo = parseError(error); + const errorInfo = parseTransactionError(error); // Handle transaction replacement as success if (errorInfo.type === 'transaction_replaced') { @@ -799,7 +695,7 @@ const Expatriation = ({ } catch (error) { console.error('❌ Transfer failed:', error); - const errorInfo = parseError(error); + const errorInfo = parseTransactionError(error); // Show toast notification toast.error( @@ -847,7 +743,7 @@ const Expatriation = ({ } } catch (error) { console.error('Error in approval check:', error); - const errorInfo = parseError(error); + const errorInfo = parseTransactionError(error); // Show toast notification toast.error( @@ -933,7 +829,7 @@ const Expatriation = ({ } catch (error) { console.error('❌ Allowance revocation failed:', error); - const errorInfo = parseError(error); + const errorInfo = parseTransactionError(error); // Handle transaction replacement as success if (errorInfo.type === 'transaction_replaced') { diff --git a/src/components/Repatriation.js b/src/components/Repatriation.js index e18c546..19f4f2e 100644 --- a/src/components/Repatriation.js +++ b/src/components/Repatriation.js @@ -7,6 +7,7 @@ import toast from 'react-hot-toast'; import { parseAndValidateReward } from '../utils/safe-reward-handler'; import { addTransferEventToStorage } from './ClaimList'; import { getBlockTimestamp } from '../utils/bridge-contracts'; +import { parseTransactionError } from '../utils/error-parser'; // Safely convert to EIP-55 checksum if it's an EVM address const toChecksumAddress = (address) => { @@ -29,85 +30,6 @@ const Repatriation = ({ const [isLoading, setIsLoading] = useState(false); const [transferTxHash, setTransferTxHash] = useState(''); - // Helper function to parse and categorize errors - const parseError = (error) => { - const errorMessage = error.message || error.toString(); - - // User rejection/cancellation - if (errorMessage.includes('user rejected') || - errorMessage.includes('ACTION_REJECTED') || - errorMessage.includes('User denied') || - errorMessage.includes('cancelled') || - error.code === 'ACTION_REJECTED') { - return { - type: 'user_rejection', - title: 'Transaction Cancelled', - message: 'You cancelled the transaction. No changes were made.', - canRetry: true, - isUserError: true - }; - } - - // Insufficient funds - if (errorMessage.includes('insufficient funds') || - errorMessage.includes('insufficient balance')) { - return { - type: 'insufficient_funds', - title: 'Insufficient Funds', - message: 'You don\'t have enough tokens or ETH to complete this transaction.', - canRetry: false, - isUserError: true - }; - } - - // Gas estimation failed - if (errorMessage.includes('gas required exceeds allowance') || - errorMessage.includes('gas estimation failed')) { - return { - type: 'gas_error', - title: 'Gas Estimation Failed', - message: 'The transaction requires more gas than available. Try increasing gas limit.', - canRetry: true, - isUserError: false - }; - } - - // Network issues - if (errorMessage.includes('network') || - errorMessage.includes('timeout') || - errorMessage.includes('connection')) { - return { - type: 'network_error', - title: 'Network Error', - message: 'There was a network issue. Please check your connection and try again.', - canRetry: true, - isUserError: false - }; - } - - // Contract/transaction errors - if (errorMessage.includes('execution reverted') || - errorMessage.includes('revert')) { - return { - type: 'contract_error', - title: 'Transaction Failed', - message: 'The transaction was rejected by the smart contract. Please check your inputs.', - canRetry: true, - isUserError: false - }; - } - - // Default error - return { - type: 'unknown', - title: 'Operation Failed', - message: errorMessage, - canRetry: true, - isUserError: false - }; - }; - - // Create import wrapper contract for repatriation const createImportWrapperContract = useCallback(async () => { // Use IMPORT_WRAPPER_ABI for repatriation functionality @@ -315,7 +237,7 @@ const Repatriation = ({ } catch (error) { console.error('❌ Repatriation failed:', error); - const errorInfo = parseError(error); + const errorInfo = parseTransactionError(error); setStep('confirm'); // Show toast notification diff --git a/src/contracts/evm_substrate/docs/COUNTERSTAKE_BRIDGE_DEPLOY.md b/src/contracts/evm_substrate/docs/COUNTERSTAKE_BRIDGE_DEPLOY.md index 97c0c1f..847d8ac 100644 --- a/src/contracts/evm_substrate/docs/COUNTERSTAKE_BRIDGE_DEPLOY.md +++ b/src/contracts/evm_substrate/docs/COUNTERSTAKE_BRIDGE_DEPLOY.md @@ -82,7 +82,7 @@ Example: brew install node # Install pnpm -npm install -g pnpm +pnpm setup # Install Rust curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh @@ -1063,7 +1063,7 @@ exports.production_oracle_prices = { ### Monitoring Setup ```bash # Set up monitoring -npm install -g pm2 +pnpm add -g pm2 pm2 start ecosystem.config.js pm2 startup pm2 save diff --git a/src/utils/__tests__/decimal-converter.test.js b/src/utils/__tests__/decimal-converter.test.js new file mode 100644 index 0000000..5cde674 --- /dev/null +++ b/src/utils/__tests__/decimal-converter.test.js @@ -0,0 +1,192 @@ +import { convertActualToDisplay, convertDisplayToActual } from '../decimal-converter'; + +describe('decimal-converter', () => { + describe('convertActualToDisplay', () => { + describe('without decimalsDisplayMultiplier', () => { + it('should return actual amount as is for standard tokens', () => { + const result = convertActualToDisplay('1.5', 18, '0xStandardToken', () => null); + expect(result).toBe('1.5'); + }); + + it('should return 0 for zero amount', () => { + const result = convertActualToDisplay('0', 18, '0xStandardToken', () => null); + expect(result).toBe('0'); + }); + + it('should return 0 for null amount', () => { + const result = convertActualToDisplay(null, 18, '0xStandardToken', () => null); + expect(result).toBe('0'); + }); + + it('should return 0 for undefined amount', () => { + const result = convertActualToDisplay(undefined, 18, '0xStandardToken', () => null); + expect(result).toBe('0'); + }); + + it('should return 0 for empty string', () => { + const result = convertActualToDisplay('', 18, '0xStandardToken', () => null); + expect(result).toBe('0'); + }); + }); + + describe('with decimalsDisplayMultiplier (P3D tokens)', () => { + const getMultiplier = (address) => { + if (address === '0xP3DToken') return 1000000; + return null; + }; + + it('should multiply by decimalsDisplayMultiplier for P3D tokens', () => { + // 0.000001 actual * 1000000 = 1.0 display + const result = convertActualToDisplay('0.000001', 18, '0xP3DToken', getMultiplier); + expect(result).toBe('1'); + }); + + it('should handle large amounts correctly', () => { + // 0.001 actual * 1000000 = 1000 display + const result = convertActualToDisplay('0.001', 18, '0xP3DToken', getMultiplier); + expect(result).toBe('1000'); + }); + + it('should handle very small amounts correctly', () => { + // 0.0000001 actual * 1000000 = 0.1 display + const result = convertActualToDisplay('0.0000001', 18, '0xP3DToken', getMultiplier); + expect(result).toBe('0.1'); + }); + + it('should strip trailing zeros', () => { + // 0.0000015 actual * 1000000 = 1.5 display + const result = convertActualToDisplay('0.0000015', 18, '0xP3DToken', getMultiplier); + expect(result).toBe('1.5'); + }); + + it('should strip trailing zeros and decimal point', () => { + // 0.000002 actual * 1000000 = 2.0 display -> '2' + const result = convertActualToDisplay('0.000002', 18, '0xP3DToken', getMultiplier); + expect(result).toBe('2'); + }); + + it('should return 0 for zero amount with P3D token', () => { + const result = convertActualToDisplay('0', 18, '0xP3DToken', getMultiplier); + expect(result).toBe('0'); + }); + + it('should limit to 6 decimal places', () => { + // 0.0000001234567 actual * 1000000 = 0.1234567 -> should be truncated to 6 decimals + const result = convertActualToDisplay('0.0000001234567', 18, '0xP3DToken', getMultiplier); + expect(result).toBe('0.123457'); // rounded to 6 decimals + }); + }); + + describe('error handling', () => { + it('should return 0 when multiplier function throws', () => { + const throwingMultiplier = () => { + throw new Error('Test error'); + }; + const result = convertActualToDisplay('1', 18, '0xToken', throwingMultiplier); + expect(result).toBe('0'); + }); + }); + }); + + describe('convertDisplayToActual', () => { + describe('without decimalsDisplayMultiplier', () => { + it('should return display amount as is for standard tokens', () => { + const result = convertDisplayToActual('1.5', 18, '0xStandardToken', () => null); + expect(result).toBe('1.5'); + }); + + it('should return 0 for zero amount', () => { + const result = convertDisplayToActual('0', 18, '0xStandardToken', () => null); + expect(result).toBe('0'); + }); + + it('should return 0 for null amount', () => { + const result = convertDisplayToActual(null, 18, '0xStandardToken', () => null); + expect(result).toBe('0'); + }); + + it('should return 0 for undefined amount', () => { + const result = convertDisplayToActual(undefined, 18, '0xStandardToken', () => null); + expect(result).toBe('0'); + }); + + it('should return 0 for empty string', () => { + const result = convertDisplayToActual('', 18, '0xStandardToken', () => null); + expect(result).toBe('0'); + }); + }); + + describe('with decimalsDisplayMultiplier (P3D tokens)', () => { + const getMultiplier = (address) => { + if (address === '0xP3DToken') return 1000000; + return null; + }; + + it('should divide by decimalsDisplayMultiplier for P3D tokens', () => { + // 1.0 display / 1000000 = 0.000001 actual + const result = convertDisplayToActual('1', 18, '0xP3DToken', getMultiplier); + expect(result).toBe('0.000001000000000000'); + }); + + it('should handle large amounts correctly', () => { + // 1000 display / 1000000 = 0.001 actual + const result = convertDisplayToActual('1000', 18, '0xP3DToken', getMultiplier); + expect(result).toBe('0.001000000000000000'); + }); + + it('should handle decimal amounts correctly', () => { + // 1.5 display / 1000000 = 0.0000015 actual + const result = convertDisplayToActual('1.5', 18, '0xP3DToken', getMultiplier); + expect(result).toBe('0.000001500000000000'); + }); + + it('should respect decimals parameter for precision', () => { + // 1.0 display / 1000000 = 0.000001 actual with 12 decimals + const result = convertDisplayToActual('1', 12, '0xP3DToken', getMultiplier); + expect(result).toBe('0.000001000000'); + }); + + it('should return 0 for zero amount with P3D token', () => { + const result = convertDisplayToActual('0', 18, '0xP3DToken', getMultiplier); + expect(result).toBe('0'); + }); + + it('should handle very large display amounts', () => { + // 1000000 display / 1000000 = 1.0 actual + const result = convertDisplayToActual('1000000', 18, '0xP3DToken', getMultiplier); + expect(result).toBe('1.000000000000000000'); + }); + }); + + describe('error handling', () => { + it('should return 0 when multiplier function throws', () => { + const throwingMultiplier = () => { + throw new Error('Test error'); + }; + const result = convertDisplayToActual('1', 18, '0xToken', throwingMultiplier); + expect(result).toBe('0'); + }); + }); + }); + + describe('round-trip conversion', () => { + const getMultiplier = (address) => { + if (address === '0xP3DToken') return 1000000; + return null; + }; + + it('should maintain value through round-trip for P3D tokens', () => { + const original = '0.000001'; + const display = convertActualToDisplay(original, 18, '0xP3DToken', getMultiplier); + const backToActual = convertDisplayToActual(display, 18, '0xP3DToken', getMultiplier); + expect(parseFloat(backToActual)).toBeCloseTo(parseFloat(original), 15); + }); + + it('should maintain value through round-trip for standard tokens', () => { + const original = '1.5'; + const display = convertActualToDisplay(original, 18, '0xStandardToken', getMultiplier); + const backToActual = convertDisplayToActual(display, 18, '0xStandardToken', getMultiplier); + expect(backToActual).toBe(original); + }); + }); +}); diff --git a/src/utils/__tests__/error-parser.test.js b/src/utils/__tests__/error-parser.test.js new file mode 100644 index 0000000..0177ee7 --- /dev/null +++ b/src/utils/__tests__/error-parser.test.js @@ -0,0 +1,309 @@ +import { parseTransactionError } from '../error-parser'; + +describe('error-parser', () => { + describe('parseTransactionError', () => { + describe('user rejection errors', () => { + it('should parse user rejected transaction error', () => { + const error = new Error('user rejected transaction'); + const result = parseTransactionError(error); + + expect(result).toEqual({ + type: 'user_rejection', + title: 'Transaction Cancelled', + message: 'You cancelled the transaction. No changes were made.', + canRetry: true, + isUserError: true + }); + }); + + it('should parse ACTION_REJECTED in message', () => { + const error = new Error('ACTION_REJECTED'); + const result = parseTransactionError(error); + + expect(result.type).toBe('user_rejection'); + }); + + it('should parse ACTION_REJECTED error code', () => { + const error = new Error('Some error'); + error.code = 'ACTION_REJECTED'; + const result = parseTransactionError(error); + + expect(result.type).toBe('user_rejection'); + }); + + it('should parse User denied message', () => { + const error = new Error('User denied transaction signature'); + const result = parseTransactionError(error); + + expect(result.type).toBe('user_rejection'); + }); + + it('should parse cancelled in message', () => { + const error = new Error('Transaction cancelled by user'); + const result = parseTransactionError(error); + + expect(result.type).toBe('user_rejection'); + }); + }); + + describe('transaction replaced errors', () => { + it('should parse transaction replaced message', () => { + const error = new Error('transaction was replaced'); + const result = parseTransactionError(error); + + expect(result).toEqual({ + 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 + }); + }); + + it('should parse TRANSACTION_REPLACED error code', () => { + const error = new Error('Some error'); + error.code = 'TRANSACTION_REPLACED'; + const result = parseTransactionError(error); + + expect(result.type).toBe('transaction_replaced'); + expect(result.isSuccess).toBe(true); + }); + }); + + describe('transaction hash errors', () => { + it('should parse transaction hash missing error', () => { + const error = new Error('Transaction does not have a transaction hash'); + const result = parseTransactionError(error); + + expect(result).toEqual({ + type: 'transaction_hash_error', + title: 'Transaction Submission Failed', + message: 'The transaction could not be submitted properly. This often happens with allowance increases.', + canRetry: true, + isUserError: false + }); + }); + + it('should parse generic problem message', () => { + const error = new Error('there was a problem'); + const result = parseTransactionError(error); + + expect(result.type).toBe('transaction_hash_error'); + }); + + it('should parse -32603 error code', () => { + const error = new Error('Some error'); + error.code = -32603; + const result = parseTransactionError(error); + + expect(result.type).toBe('transaction_hash_error'); + }); + }); + + describe('insufficient funds errors', () => { + it('should parse insufficient funds message', () => { + const error = new Error('insufficient funds for gas * price + value'); + const result = parseTransactionError(error); + + expect(result).toEqual({ + type: 'insufficient_funds', + title: 'Insufficient Funds', + message: 'You don\'t have enough tokens or ETH to complete this transaction.', + canRetry: false, + isUserError: true + }); + }); + + it('should parse insufficient balance message', () => { + const error = new Error('insufficient balance'); + const result = parseTransactionError(error); + + expect(result.type).toBe('insufficient_funds'); + expect(result.canRetry).toBe(false); + }); + }); + + describe('gas estimation errors', () => { + it('should parse gas required exceeds allowance', () => { + const error = new Error('gas required exceeds allowance or always failing transaction'); + const result = parseTransactionError(error); + + expect(result).toEqual({ + type: 'gas_error', + title: 'Gas Estimation Failed', + message: 'The transaction requires more gas than available. Try increasing gas limit.', + canRetry: true, + isUserError: false + }); + }); + + it('should parse gas estimation failed message', () => { + const error = new Error('gas estimation failed'); + const result = parseTransactionError(error); + + expect(result.type).toBe('gas_error'); + }); + }); + + describe('network errors', () => { + it('should parse network error', () => { + const error = new Error('network request failed'); + const result = parseTransactionError(error); + + expect(result).toEqual({ + type: 'network_error', + title: 'Network Error', + message: 'There was a network issue. Please check your connection and try again.', + canRetry: true, + isUserError: false + }); + }); + + it('should parse timeout error', () => { + const error = new Error('timeout waiting for response'); + const result = parseTransactionError(error); + + expect(result.type).toBe('network_error'); + }); + + it('should parse connection error', () => { + const error = new Error('connection refused'); + const result = parseTransactionError(error); + + expect(result.type).toBe('network_error'); + }); + }); + + describe('contract errors', () => { + it('should parse execution reverted error', () => { + const error = new Error('execution reverted: ERC20: insufficient allowance'); + const result = parseTransactionError(error); + + expect(result).toEqual({ + type: 'contract_error', + title: 'Transaction Failed', + message: 'The transaction was rejected by the smart contract. Please check your inputs.', + canRetry: true, + isUserError: false + }); + }); + + it('should parse revert error', () => { + const error = new Error('Transaction reverted without a reason string'); + const result = parseTransactionError(error); + + expect(result.type).toBe('contract_error'); + }); + }); + + describe('unknown errors', () => { + it('should parse unknown error with message', () => { + const error = new Error('Unexpected error occurred'); + const result = parseTransactionError(error); + + expect(result).toEqual({ + type: 'unknown', + title: 'Operation Failed', + message: 'Unexpected error occurred', + canRetry: true, + isUserError: false + }); + }); + + it('should handle error without message property', () => { + const error = { toString: () => 'Custom error string' }; + const result = parseTransactionError(error); + + expect(result.type).toBe('unknown'); + expect(result.message).toBe('Custom error string'); + }); + + it('should handle empty error message', () => { + const error = new Error(''); + const result = parseTransactionError(error); + + expect(result.type).toBe('unknown'); + expect(result.message).toBe('Error'); + }); + }); + + describe('error priority', () => { + it('should prioritize user rejection over network error', () => { + const error = new Error('user rejected network transaction'); + const result = parseTransactionError(error); + + expect(result.type).toBe('user_rejection'); + }); + + it('should prioritize transaction replaced over contract error', () => { + const error = new Error('transaction was replaced and reverted'); + const result = parseTransactionError(error); + + expect(result.type).toBe('transaction_replaced'); + }); + + it('should prioritize insufficient funds over gas error', () => { + const error = new Error('insufficient funds - gas estimation failed'); + const result = parseTransactionError(error); + + expect(result.type).toBe('insufficient_funds'); + }); + }); + + describe('edge cases', () => { + it('should handle null error', () => { + const result = parseTransactionError(null); + + expect(result.type).toBe('unknown'); + }); + + it('should handle undefined error', () => { + const result = parseTransactionError(undefined); + + expect(result.type).toBe('unknown'); + }); + + it('should be case-sensitive for error matching', () => { + const error = new Error('USER REJECTED transaction'); + const result = parseTransactionError(error); + + expect(result.type).toBe('unknown'); + }); + }); + + describe('return value structure', () => { + it('should always return required fields', () => { + const error = new Error('test'); + const result = parseTransactionError(error); + + expect(result).toHaveProperty('type'); + expect(result).toHaveProperty('title'); + expect(result).toHaveProperty('message'); + expect(result).toHaveProperty('canRetry'); + expect(result).toHaveProperty('isUserError'); + }); + + it('should have boolean canRetry field', () => { + const error = new Error('user rejected transaction'); + const result = parseTransactionError(error); + + expect(typeof result.canRetry).toBe('boolean'); + }); + + it('should have boolean isUserError field', () => { + const error = new Error('user rejected transaction'); + const result = parseTransactionError(error); + + expect(typeof result.isUserError).toBe('boolean'); + }); + + it('should have string type field', () => { + const error = new Error('user rejected transaction'); + const result = parseTransactionError(error); + + expect(typeof result.type).toBe('string'); + }); + }); + }); +}); diff --git a/src/utils/__tests__/network-switcher.test.js b/src/utils/__tests__/network-switcher.test.js new file mode 100644 index 0000000..88a0acc --- /dev/null +++ b/src/utils/__tests__/network-switcher.test.js @@ -0,0 +1,248 @@ +import { switchNetwork } from '../network-switcher'; + +describe('network-switcher', () => { + let mockEthereum; + let originalWindow; + + beforeEach(() => { + originalWindow = global.window; + mockEthereum = { + request: jest.fn(), + }; + delete global.window; + global.window = { ethereum: mockEthereum }; + }); + + afterEach(() => { + jest.clearAllMocks(); + global.window = originalWindow; + }); + + describe('switchNetwork', () => { + const mockNetworkConfig = { + id: 1, + name: 'Ethereum', + chainId: 1, + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrl: 'https://eth.llamarpc.com', + explorer: 'https://etherscan.io', + }; + + describe('successful network switch', () => { + it('should switch to network successfully', async () => { + mockEthereum.request.mockResolvedValueOnce(null); + + const result = await switchNetwork(mockNetworkConfig); + + expect(result).toBe(true); + expect(mockEthereum.request).toHaveBeenCalledWith({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x1' }], + }); + expect(mockEthereum.request).toHaveBeenCalledTimes(1); + }); + + it('should convert decimal chain ID to hex correctly', async () => { + const bscNetwork = { + ...mockNetworkConfig, + id: 56, + chainId: 56, + name: 'BSC', + }; + mockEthereum.request.mockResolvedValueOnce(null); + + await switchNetwork(bscNetwork); + + expect(mockEthereum.request).toHaveBeenCalledWith({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x38' }], + }); + }); + + it('should handle large chain IDs', async () => { + const customNetwork = { + ...mockNetworkConfig, + id: 100000, + chainId: 100000, + }; + mockEthereum.request.mockResolvedValueOnce(null); + + await switchNetwork(customNetwork); + + expect(mockEthereum.request).toHaveBeenCalledWith({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x186a0' }], + }); + }); + }); + + describe('network not added (error code 4902)', () => { + it('should add network when not found in MetaMask', async () => { + const notAddedError = new Error('Network not added'); + notAddedError.code = 4902; + + mockEthereum.request + .mockRejectedValueOnce(notAddedError) + .mockResolvedValueOnce(null); + + const result = await switchNetwork(mockNetworkConfig); + + expect(result).toBe(true); + expect(mockEthereum.request).toHaveBeenCalledTimes(2); + expect(mockEthereum.request).toHaveBeenNthCalledWith(1, { + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x1' }], + }); + expect(mockEthereum.request).toHaveBeenNthCalledWith(2, { + method: 'wallet_addEthereumChain', + params: [{ + chainId: '0x1', + chainName: 'Ethereum', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: ['https://eth.llamarpc.com'], + blockExplorerUrls: ['https://etherscan.io'], + }], + }); + }); + + it('should return false when adding network fails', async () => { + const notAddedError = new Error('Network not added'); + notAddedError.code = 4902; + const addError = new Error('User rejected'); + + mockEthereum.request + .mockRejectedValueOnce(notAddedError) + .mockRejectedValueOnce(addError); + + const result = await switchNetwork(mockNetworkConfig); + + expect(result).toBe(false); + expect(mockEthereum.request).toHaveBeenCalledTimes(2); + }); + }); + + describe('user rejection (error code 4001)', () => { + it('should return false when user rejects network switch', async () => { + const userRejectedError = new Error('User rejected'); + userRejectedError.code = 4001; + + mockEthereum.request.mockRejectedValueOnce(userRejectedError); + + const result = await switchNetwork(mockNetworkConfig); + + expect(result).toBe(false); + expect(mockEthereum.request).toHaveBeenCalledTimes(1); + }); + }); + + describe('other errors', () => { + it('should return false for unknown errors', async () => { + const unknownError = new Error('Unknown error'); + unknownError.code = 9999; + + mockEthereum.request.mockRejectedValueOnce(unknownError); + + const result = await switchNetwork(mockNetworkConfig); + + expect(result).toBe(false); + expect(mockEthereum.request).toHaveBeenCalledTimes(1); + }); + + it('should return false when request throws without error code', async () => { + mockEthereum.request.mockRejectedValueOnce(new Error('Generic error')); + + const result = await switchNetwork(mockNetworkConfig); + + expect(result).toBe(false); + }); + }); + + describe('MetaMask not available', () => { + it('should return false when window.ethereum is not available', async () => { + global.window = {}; + + const result = await switchNetwork(mockNetworkConfig); + + expect(result).toBe(false); + }); + + it('should return false when window is not defined', async () => { + global.window = undefined; + + const result = await switchNetwork(mockNetworkConfig); + + expect(result).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should handle null network config', async () => { + const result = await switchNetwork(null); + + expect(result).toBe(false); + expect(mockEthereum.request).not.toHaveBeenCalled(); + }); + + it('should handle undefined network config', async () => { + const result = await switchNetwork(undefined); + + expect(result).toBe(false); + expect(mockEthereum.request).not.toHaveBeenCalled(); + }); + + it('should handle network config without chainId', async () => { + const invalidNetwork = { + name: 'Invalid Network', + }; + + const result = await switchNetwork(invalidNetwork); + + expect(result).toBe(false); + }); + + it('should handle network config with chainId 0', async () => { + const zeroChainNetwork = { + ...mockNetworkConfig, + id: 0, + chainId: 0, + }; + mockEthereum.request.mockResolvedValueOnce(null); + + const result = await switchNetwork(zeroChainNetwork); + + expect(result).toBe(true); + expect(mockEthereum.request).toHaveBeenCalledWith({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x0' }], + }); + }); + }); + + describe('return value consistency', () => { + it('should always return a boolean', async () => { + mockEthereum.request.mockResolvedValueOnce(null); + + const result = await switchNetwork(mockNetworkConfig); + + expect(typeof result).toBe('boolean'); + }); + + it('should return boolean even on errors', async () => { + mockEthereum.request.mockRejectedValueOnce(new Error('Test error')); + + const result = await switchNetwork(mockNetworkConfig); + + expect(typeof result).toBe('boolean'); + expect(result).toBe(false); + }); + }); + }); +}); diff --git a/src/utils/__tests__/retry-with-fallback.test.js b/src/utils/__tests__/retry-with-fallback.test.js index ee9c287..3cd17dc 100644 --- a/src/utils/__tests__/retry-with-fallback.test.js +++ b/src/utils/__tests__/retry-with-fallback.test.js @@ -99,14 +99,14 @@ describe('Retry with Fallback', () => { describe('ProviderHealthMonitor', () => { it('should track provider health correctly', () => { const monitor = new ProviderHealthMonitor(); - + // Record some requests monitor.recordRequest('ETHEREUM', null, true, 100); monitor.recordRequest('ETHEREUM', null, true, 200); monitor.recordRequest('ETHEREUM', null, false, 300, new Error('429')); - + const health = monitor.getProviderHealth('ETHEREUM'); - expect(health).toBe('degraded'); // 2/3 success rate + expect(health).toBe('rate_limited'); // 1/3 rate limit ratio (33%) exceeds 30% threshold }); it('should detect rate limiting', () => { diff --git a/src/utils/decimal-converter.js b/src/utils/decimal-converter.js new file mode 100644 index 0000000..ccafc19 --- /dev/null +++ b/src/utils/decimal-converter.js @@ -0,0 +1,39 @@ +export const convertActualToDisplay = (actualAmount, decimals, tokenAddress, getTokenDecimalsDisplayMultiplier) => { + try { + if (!actualAmount || parseFloat(actualAmount) === 0) return '0'; + + const num = parseFloat(actualAmount); + + if (tokenAddress) { + const decimalsDisplayMultiplier = getTokenDecimalsDisplayMultiplier(tokenAddress); + if (decimalsDisplayMultiplier) { + const displayNumber = num * decimalsDisplayMultiplier; + return displayNumber.toFixed(6).replace(/\.?0+$/, '') || '0'; + } + } + + return actualAmount; + } catch (error) { + return '0'; + } +}; + +export const convertDisplayToActual = (displayAmount, decimals, tokenAddress, getTokenDecimalsDisplayMultiplier) => { + try { + if (!displayAmount || parseFloat(displayAmount) === 0) return '0'; + + const num = parseFloat(displayAmount); + + if (tokenAddress) { + const decimalsDisplayMultiplier = getTokenDecimalsDisplayMultiplier(tokenAddress); + if (decimalsDisplayMultiplier) { + const actualNumber = num / decimalsDisplayMultiplier; + return actualNumber.toFixed(decimals); + } + } + + return displayAmount; + } catch (error) { + return '0'; + } +}; diff --git a/src/utils/error-parser.js b/src/utils/error-parser.js new file mode 100644 index 0000000..cbc3761 --- /dev/null +++ b/src/utils/error-parser.js @@ -0,0 +1,104 @@ +export const parseTransactionError = (error) => { + if (!error) { + return { + type: 'unknown', + title: 'Operation Failed', + message: '', + canRetry: true, + isUserError: false + }; + } + + const errorMessage = error.message || error.toString(); + + if (errorMessage.includes('user rejected') || + errorMessage.includes('ACTION_REJECTED') || + errorMessage.includes('User denied') || + errorMessage.includes('cancelled') || + error.code === 'ACTION_REJECTED') { + return { + type: 'user_rejection', + title: 'Transaction Cancelled', + message: 'You cancelled the transaction. No changes were made.', + canRetry: true, + isUserError: true + }; + } + + if (errorMessage.includes('transaction was replaced') || + error.code === 'TRANSACTION_REPLACED') { + return { + 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 + }; + } + + if (errorMessage.includes('Transaction does not have a transaction hash') || + errorMessage.includes('there was a problem') || + error.code === -32603) { + return { + type: 'transaction_hash_error', + title: 'Transaction Submission Failed', + message: 'The transaction could not be submitted properly. This often happens with allowance increases.', + canRetry: true, + isUserError: false + }; + } + + if (errorMessage.includes('insufficient funds') || + errorMessage.includes('insufficient balance')) { + return { + type: 'insufficient_funds', + title: 'Insufficient Funds', + message: 'You don\'t have enough tokens or ETH to complete this transaction.', + canRetry: false, + isUserError: true + }; + } + + if (errorMessage.includes('gas required exceeds allowance') || + errorMessage.includes('gas estimation failed')) { + return { + type: 'gas_error', + title: 'Gas Estimation Failed', + message: 'The transaction requires more gas than available. Try increasing gas limit.', + canRetry: true, + isUserError: false + }; + } + + if (errorMessage.includes('network') || + errorMessage.includes('timeout') || + errorMessage.includes('connection')) { + return { + type: 'network_error', + title: 'Network Error', + message: 'There was a network issue. Please check your connection and try again.', + canRetry: true, + isUserError: false + }; + } + + if (errorMessage.includes('execution reverted') || + errorMessage.includes('revert')) { + return { + type: 'contract_error', + title: 'Transaction Failed', + message: 'The transaction was rejected by the smart contract. Please check your inputs.', + canRetry: true, + isUserError: false + }; + } + + return { + type: 'unknown', + title: 'Operation Failed', + message: errorMessage, + canRetry: true, + isUserError: false + }; +}; diff --git a/src/utils/network-switcher.js b/src/utils/network-switcher.js new file mode 100644 index 0000000..24630f4 --- /dev/null +++ b/src/utils/network-switcher.js @@ -0,0 +1,46 @@ +export const switchNetwork = async (networkConfig) => { + if (!networkConfig || (networkConfig.chainId === undefined && networkConfig.id === undefined)) { + return false; + } + + const win = typeof window !== 'undefined' ? window : (typeof global !== 'undefined' && global.window); + if (!win || !win.ethereum) { + return false; + } + + try { + const chainId = networkConfig.chainId !== undefined ? networkConfig.chainId : networkConfig.id; + const chainIdHex = `0x${chainId.toString(16)}`; + + await win.ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: chainIdHex }], + }); + + return true; + } catch (error) { + if (error.code === 4902) { + try { + const chainId = networkConfig.chainId !== undefined ? networkConfig.chainId : networkConfig.id; + const chainIdHex = `0x${chainId.toString(16)}`; + + await win.ethereum.request({ + method: 'wallet_addEthereumChain', + params: [{ + chainId: chainIdHex, + chainName: networkConfig.name, + nativeCurrency: networkConfig.nativeCurrency, + rpcUrls: [networkConfig.rpcUrl], + blockExplorerUrls: [networkConfig.explorer], + }], + }); + + return true; + } catch (addError) { + return false; + } + } + + return false; + } +}; diff --git a/src/utils/provider-manager.js b/src/utils/provider-manager.js index 1e33ab4..0a0fe76 100644 --- a/src/utils/provider-manager.js +++ b/src/utils/provider-manager.js @@ -373,6 +373,9 @@ class ProviderManager { } } +// Export class for testing +export { ProviderManager }; + // Create singleton instance const providerManager = new ProviderManager(); From 30a08819f905c94ca5188706c9589c1f65958a31 Mon Sep 17 00:00:00 2001 From: Mikhail Fedosov Date: Tue, 28 Oct 2025 14:40:20 +0400 Subject: [PATCH 2/3] chore: add .claude/ and .pnpm-store/ to .gitignore --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index dc53cee..fb6c944 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,12 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* +# pnpm store +.pnpm-store/ + +# Claude Code +.claude/ + # Production builds /build /dist From 83a587ad0f76fd40dba2d3e3edd208054a1e9249 Mon Sep 17 00:00:00 2001 From: Mikhail Fedosov Date: Tue, 28 Oct 2025 15:02:29 +0400 Subject: [PATCH 3/3] fix: remove pnpm version from CI workflow to avoid conflict with packageManager The pnpm/action-setup@v4 action auto-detects version from package.json's packageManager field. Having both causes ERR_PNPM_BAD_PM_VERSION conflict. --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4701f44..d5c9385 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4