Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions src/components/web3/TransactionManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,16 @@ export const TransactionManager: React.FC<TransactionManagerProps> = ({
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
Expand All @@ -108,7 +115,14 @@ export const TransactionManager: React.FC<TransactionManagerProps> = ({
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;
})();

Expand All @@ -127,7 +141,7 @@ export const TransactionManager: React.FC<TransactionManagerProps> = ({
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<TransactionDetails> = {
to: toAddress,
value: `0x${valueInWei}`,
Expand Down
14 changes: 6 additions & 8 deletions src/components/web3/WalletConnector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,14 +170,12 @@ export const WalletConnector: React.FC<WalletConnectorProps> = ({
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-700 dark:text-red-300">{wallet.error}</p>
{wallet.error.includes('not installed') && (
<button
onClick={() => wallet.clearError()}
className="text-xs text-red-600 dark:text-red-400 hover:underline mt-1"
>
Dismiss
</button>
)}
<button
onClick={() => wallet.clearError()}
className="text-xs text-red-600 dark:text-red-400 hover:underline mt-1 block"
>
Dismiss
</button>
</div>
</div>
)}
Expand Down
78 changes: 78 additions & 0 deletions src/components/web3/__tests__/WalletConnector.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<WalletConnector />);
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(<WalletConnector />);
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(<WalletConnector />);
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();
});
});
158 changes: 158 additions & 0 deletions src/hooks/__tests__/useWeb3Wallet.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
8 changes: 5 additions & 3 deletions src/hooks/useWeb3Wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions src/utils/web3/walletValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
};
}

Expand Down
Loading