diff --git a/src/components/web3/TransactionManager.tsx b/src/components/web3/TransactionManager.tsx index 85831f86..72055201 100644 --- a/src/components/web3/TransactionManager.tsx +++ b/src/components/web3/TransactionManager.tsx @@ -93,9 +93,16 @@ export const TransactionManager: React.FC = ({ const validateForm = useCallback((): string | null => { if (!toAddress.trim()) return 'Recipient address is required'; if (!amount || parseFloat(amount) <= 0) return 'Amount must be greater than 0'; - if (!/^0x[a-fA-F0-9]{40}$/.test(toAddress)) return 'Invalid Ethereum address format'; + const isValid = wallet.provider === 'starknet' + ? /^0x[a-fA-F0-9]{60,66}$/.test(toAddress) + : /^0x[a-fA-F0-9]{40}$/.test(toAddress); + if (!isValid) { + return wallet.provider === 'starknet' + ? 'Invalid Starknet address format' + : 'Invalid Ethereum address format'; + } return null; - }, [toAddress, amount]); + }, [toAddress, amount, wallet.provider]); /** * Handle transaction submission @@ -108,7 +115,14 @@ export const TransactionManager: React.FC = ({ const validationError = (() => { if (!toAddress.trim()) return 'Recipient address is required'; if (!amount || parseFloat(amount) <= 0) return 'Amount must be greater than 0'; - if (!/^0x[a-fA-F0-9]{40}$/.test(toAddress)) return 'Invalid Ethereum address format'; + const isValid = wallet.provider === 'starknet' + ? /^0x[a-fA-F0-9]{60,66}$/.test(toAddress) + : /^0x[a-fA-F0-9]{40}$/.test(toAddress); + if (!isValid) { + return wallet.provider === 'starknet' + ? 'Invalid Starknet address format' + : 'Invalid Ethereum address format'; + } return null; })(); @@ -127,7 +141,7 @@ export const TransactionManager: React.FC = ({ setTxHash(null); try { - const valueInWei = (parseFloat(amount) * Math.pow(10, 18)).toString(16); + const valueInWei = BigInt(Math.round(parseFloat(amount) * Math.pow(10, 18))).toString(16); const tx: Partial = { to: toAddress, value: `0x${valueInWei}`, diff --git a/src/components/web3/WalletConnector.tsx b/src/components/web3/WalletConnector.tsx index b8196c74..1d767a60 100644 --- a/src/components/web3/WalletConnector.tsx +++ b/src/components/web3/WalletConnector.tsx @@ -170,14 +170,12 @@ export const WalletConnector: React.FC = ({

{wallet.error}

- {wallet.error.includes('not installed') && ( - - )} +
)} diff --git a/src/components/web3/__tests__/WalletConnector.test.tsx b/src/components/web3/__tests__/WalletConnector.test.tsx new file mode 100644 index 00000000..a6754eba --- /dev/null +++ b/src/components/web3/__tests__/WalletConnector.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { WalletConnector } from '../WalletConnector'; +import { useWeb3Wallet } from '@/hooks/useWeb3Wallet'; + +vi.mock('@/hooks/useWeb3Wallet', () => ({ + useWeb3Wallet: vi.fn(), +})); + +describe('WalletConnector', () => { + const mockUseWeb3Wallet = useWeb3Wallet as any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders "Connect Wallet" button when disconnected', () => { + mockUseWeb3Wallet.mockReturnValue({ + isConnected: false, + isConnecting: false, + address: null, + provider: null, + chainId: null, + balances: [], + error: null, + connect: vi.fn(), + disconnect: vi.fn(), + clearError: vi.fn(), + }); + + render(); + expect(screen.getByRole('button', { name: /connect/i })).toBeInTheDocument(); + }); + + it('renders "Connecting..." when wallet is connecting', () => { + mockUseWeb3Wallet.mockReturnValue({ + isConnected: false, + isConnecting: true, + address: null, + provider: null, + chainId: null, + balances: [], + error: null, + connect: vi.fn(), + disconnect: vi.fn(), + clearError: vi.fn(), + }); + + render(); + expect(screen.getByText(/connecting/i)).toBeInTheDocument(); + }); + + it('renders error message and "Dismiss" button for any error type', () => { + const clearErrorMock = vi.fn(); + mockUseWeb3Wallet.mockReturnValue({ + isConnected: false, + isConnecting: false, + address: null, + provider: null, + chainId: null, + balances: [], + error: 'Some custom Starknet connection error occurred', + connect: vi.fn(), + disconnect: vi.fn(), + clearError: clearErrorMock, + }); + + render(); + expect(screen.getByText(/some custom starknet connection error occurred/i)).toBeInTheDocument(); + + const dismissButton = screen.getByRole('button', { name: /dismiss/i }); + expect(dismissButton).toBeInTheDocument(); + + fireEvent.click(dismissButton); + expect(clearErrorMock).toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/__tests__/useWeb3Wallet.test.ts b/src/hooks/__tests__/useWeb3Wallet.test.ts new file mode 100644 index 00000000..e4949b7d --- /dev/null +++ b/src/hooks/__tests__/useWeb3Wallet.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useWeb3Wallet } from '../useWeb3Wallet'; + +describe('useWeb3Wallet', () => { + let mockEthereum: any; + let mockStarknet: any; + + beforeEach(() => { + vi.useFakeTimers(); + if (typeof window !== 'undefined') { + // Clear localStorage + localStorage.clear(); + + // Mock window.ethereum + mockEthereum = { + request: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + }; + Object.defineProperty(window, 'ethereum', { + writable: true, + configurable: true, + value: mockEthereum, + }); + + // Mock window.starknet + mockStarknet = { + enable: vi.fn(), + selectedAddress: '0xstarkaddress123456789012345678901234567890123456789012345678901234567890', + on: vi.fn(), + removeListener: vi.fn(), + }; + Object.defineProperty(window, 'starknet', { + writable: true, + configurable: true, + value: mockStarknet, + }); + } + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + if (typeof window !== 'undefined') { + delete (window as any).ethereum; + delete (window as any).starknet; + } + }); + + it('should initialize with disconnected state', () => { + const { result } = renderHook(() => useWeb3Wallet()); + + expect(result.current.isConnected).toBe(false); + expect(result.current.address).toBeNull(); + expect(result.current.provider).toBeNull(); + expect(result.current.chainId).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('should successfully connect to MetaMask', async () => { + mockEthereum.request.mockImplementation(async ({ method }: { method: string }) => { + if (method === 'eth_requestAccounts') return ['0x1234567890123456789012345678901234567890']; + if (method === 'eth_chainId') return '0x1'; + return null; + }); + + const { result } = renderHook(() => useWeb3Wallet()); + + await act(async () => { + await result.current.connect('metamask'); + }); + + expect(result.current.isConnected).toBe(true); + expect(result.current.address).toBe('0x1234567890123456789012345678901234567890'); + expect(result.current.provider).toBe('metamask'); + expect(result.current.chainId).toBe('0x1'); + expect(result.current.error).toBeNull(); + expect(localStorage.getItem('wallet_connected')).toBe('true'); + expect(localStorage.getItem('wallet_provider')).toBe('metamask'); + }); + + it('should successfully connect to Starknet', async () => { + mockStarknet.enable.mockResolvedValue(['0xstarkaddress123456789012345678901234567890123456789012345678901234567890']); + + const { result } = renderHook(() => useWeb3Wallet()); + + await act(async () => { + await result.current.connect('starknet'); + }); + + expect(result.current.isConnected).toBe(true); + expect(result.current.address).toBe('0xstarkaddress123456789012345678901234567890123456789012345678901234567890'); + expect(result.current.provider).toBe('starknet'); + expect(result.current.chainId).toBe('starknet'); + expect(result.current.error).toBeNull(); + expect(localStorage.getItem('wallet_connected')).toBe('true'); + expect(localStorage.getItem('wallet_provider')).toBe('starknet'); + }); + + it('should disconnect successfully', async () => { + mockEthereum.request.mockImplementation(async ({ method }: { method: string }) => { + if (method === 'eth_requestAccounts') return ['0x1234567890123456789012345678901234567890']; + if (method === 'eth_chainId') return '0x1'; + return null; + }); + + const { result } = renderHook(() => useWeb3Wallet()); + + await act(async () => { + await result.current.connect('metamask'); + }); + + expect(result.current.isConnected).toBe(true); + + await act(async () => { + await result.current.disconnect(); + }); + + expect(result.current.isConnected).toBe(false); + expect(result.current.address).toBeNull(); + expect(result.current.provider).toBeNull(); + expect(localStorage.getItem('wallet_connected')).toBeNull(); + }); + + it('should prevent MetaMask events from corrupting Starknet connection state', async () => { + let mockAccountListener: ((accounts: string[]) => void) | null = null; + mockEthereum.on.mockImplementation((event: string, callback: any) => { + if (event === 'accountsChanged') { + mockAccountListener = callback; + } + }); + + mockStarknet.enable.mockResolvedValue(['0xstarkaddress']); + + const { result } = renderHook(() => useWeb3Wallet()); + + // Connect to Starknet + await act(async () => { + await result.current.connect('starknet'); + }); + + expect(result.current.provider).toBe('starknet'); + expect(result.current.address).toBe('0xstarkaddress'); + + // Trigger accountsChanged event on MetaMask window.ethereum + act(() => { + if (mockAccountListener) { + mockAccountListener([]); + } + }); + + // Starknet connection should remain intact + expect(result.current.isConnected).toBe(true); + expect(result.current.provider).toBe('starknet'); + expect(result.current.address).toBe('0xstarkaddress'); + }); +}); diff --git a/src/hooks/useWeb3Wallet.ts b/src/hooks/useWeb3Wallet.ts index 54d2d20b..4121d225 100644 --- a/src/hooks/useWeb3Wallet.ts +++ b/src/hooks/useWeb3Wallet.ts @@ -184,8 +184,9 @@ export function useWeb3Wallet() { setState((prev) => ({ ...prev, isConnecting: true, error: null })); try { - if (!walletInteractionRef.current.canInteract) { - throw new Error(walletInteractionRef.current.reason || 'Wallet interactions disabled'); + const interaction = validateWalletInteraction(); + if (!interaction.canInteract) { + throw new Error(interaction.reason || 'Wallet interactions disabled'); } let result; @@ -378,6 +379,7 @@ export function useWeb3Wallet() { */ useEffect(() => { if (typeof window === 'undefined') return; + if (state.provider !== 'metamask') return; const ethereum = (window as Window & { ethereum?: any }).ethereum; if (!ethereum) return; @@ -402,7 +404,7 @@ export function useWeb3Wallet() { ethereum.removeListener('accountsChanged', handleAccountsChanged); ethereum.removeListener('chainChanged', handleChainChanged); }; - }, [state.address, disconnect]); + }, [state.address, state.provider, disconnect]); return { ...state, diff --git a/src/utils/web3/walletValidation.ts b/src/utils/web3/walletValidation.ts index 76b43e46..575fe829 100644 --- a/src/utils/web3/walletValidation.ts +++ b/src/utils/web3/walletValidation.ts @@ -21,13 +21,14 @@ export function validateWalletInteraction(): WalletInteractionResult { }; } - // Check for Starknet wallet + // Check if any supported wallet is available (MetaMask or Starknet) + const hasEthereum = !!(window as Window & { ethereum?: unknown }).ethereum; const hasStarknet = !!(window as Window & { starknet?: unknown }).starknet; - if (!hasStarknet) { + if (!hasEthereum && !hasStarknet) { return { canInteract: false, - reason: 'No Starknet wallet extension detected', + reason: 'No Web3 wallet extension detected. Please install MetaMask, ArgentX, or Braavos.', }; }