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
46 changes: 46 additions & 0 deletions src/hooks/__tests__/useWeb3Wallet.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useWeb3Wallet } from '../useWeb3Wallet';
import { walletConnectionQueue } from '@/utils/web3/walletQueue';

describe('useWeb3Wallet', () => {
let mockEthereum: any;
Expand Down Expand Up @@ -43,6 +44,7 @@ describe('useWeb3Wallet', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
walletConnectionQueue.clear('cleanup');
if (typeof window !== 'undefined') {
delete (window as any).ethereum;
delete (window as any).starknet;
Expand Down Expand Up @@ -191,4 +193,48 @@ describe('useWeb3Wallet', () => {
expect(result.current.isConnected).toBe(false);
expect(result.current.address).toBeNull();
});

it('should expose queueStats with correct shape', () => {
const { result } = renderHook(() => useWeb3Wallet());
const { queueStats } = result.current;

expect(queueStats).toHaveProperty('queueLength');
expect(queueStats).toHaveProperty('isProcessing');
expect(queueStats).toHaveProperty('totalProcessed');
expect(queueStats).toHaveProperty('totalFailed');
expect(typeof queueStats.queueLength).toBe('number');
expect(typeof queueStats.isProcessing).toBe('boolean');
});

it('should serialise concurrent connect calls so only one runs at a time', async () => {
const callOrder: number[] = [];
let callCount = 0;

mockEthereum.request.mockImplementation(
({ method }: { method: string }) =>
new Promise<string | string[]>((resolve) => {
const id = ++callCount;
setTimeout(() => {
callOrder.push(id);
if (method === 'eth_requestAccounts')
resolve(['0x1234567890123456789012345678901234567890']);
else resolve('0x1');
}, 10);
}),
);

const { result } = renderHook(() => useWeb3Wallet());

await act(async () => {
await Promise.all([
result.current.connect('metamask'),
result.current.connect('metamask'),
]);
vi.runAllTimers();
});

// FIFO: first call's eth_requestAccounts resolves before the second call starts
expect(callOrder[0]).toBeLessThan(callOrder[callOrder.length - 1]);
expect(result.current.isConnected).toBe(true);
});
});
108 changes: 57 additions & 51 deletions src/hooks/useWeb3Wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,63 +206,68 @@ export function useWeb3Wallet() {
}, []);

/**
* Connect to specified wallet provider, now supports 'service'
* Connect to specified wallet provider, now supports 'service'.
* All calls are serialised through walletConnectionQueue so that
* rapid successive invocations never fire concurrent wallet pop-ups
* or produce conflicting state updates.
*/
const connect = useCallback(
async (provider: WalletProvider = 'metamask') => {
setState((prev) => ({ ...prev, isConnecting: true, error: null }));

try {
const interaction = validateWalletInteraction();
if (!interaction.canInteract) {
throw new Error(interaction.reason || 'Wallet interactions disabled');
}

let result;
switch (provider) {
case 'metamask':
result = await connectMetaMask();
break;
case 'starknet':
result = await connectStarknet();
break;
case 'service':
result = await connectServiceAccount();
break;
default:
throw new Error(`Unsupported wallet provider: ${provider}`);
}

if (!result.success || !result.data) {
throw new Error(result.error || 'Connection failed');
}

setState((prev) => ({
...prev,
address: result.data.address,
isConnected: true,
isConnecting: false,
provider: result.data.provider,
chainId: result.data.chainId,
error: null,
}));

// Persist connection preference
if (typeof localStorage !== 'undefined') {
localStorage.setItem('wallet_provider', provider);
localStorage.setItem('wallet_connected', 'true');
return walletConnectionQueue.enqueue(async () => {
try {
const interaction = validateWalletInteraction();
if (!interaction.canInteract) {
throw new Error(interaction.reason || 'Wallet interactions disabled');
}

let result;
switch (provider) {
case 'metamask':
result = await connectMetaMask();
break;
case 'starknet':
result = await connectStarknet();
break;
case 'service':
result = await connectServiceAccount();
break;
default:
throw new Error(`Unsupported wallet provider: ${provider}`);
}

if (!result.success || !result.data) {
throw new Error(result.error || 'Connection failed');
}

setState((prev) => ({
...prev,
address: result.data.address,
isConnected: true,
isConnecting: false,
provider: result.data.provider,
chainId: result.data.chainId,
error: null,
}));

// Persist connection preference
if (typeof localStorage !== 'undefined') {
localStorage.setItem('wallet_provider', provider);
localStorage.setItem('wallet_connected', 'true');
}

return result.data;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to connect wallet';
setState((prev) => ({
...prev,
isConnecting: false,
error: message,
}));
throw error;
}

return result.data;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to connect wallet';
setState((prev) => ({
...prev,
isConnecting: false,
error: message,
}));
throw error;
}
});
},
[connectMetaMask, connectStarknet, connectServiceAccount],
);
Expand Down Expand Up @@ -535,5 +540,6 @@ export function useWeb3Wallet() {
fetchBalance,
clearError,
supportedChains: SUPPORTED_CHAINS,
queueStats: walletConnectionQueue.getStats(),
};
}
76 changes: 40 additions & 36 deletions src/providers/WalletProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import React, {
useEffect,
ReactNode,
} from 'react';
import { walletConnectionQueue } from '@/utils/web3/walletQueue';

// Environment validation for wallet config
const validateWalletEnv = () => {
Expand Down Expand Up @@ -70,48 +71,51 @@ export function WalletProvider({ children }: WalletProviderProps) {
const connect = useCallback(async () => {
setState((prev) => ({ ...prev, isConnecting: true, error: null }));

try {
if (typeof window === 'undefined') {
throw new Error('Window not available');
}
return walletConnectionQueue.enqueue(async () => {
try {
if (typeof window === 'undefined') {
throw new Error('Window not available');
}

const starknet = (
window as Window & {
starknet?: {
enable: () => Promise<string[]>;
selectedAddress?: string;
isConnected?: boolean;
};
const starknet = (
window as Window & {
starknet?: {
enable: () => Promise<string[]>;
selectedAddress?: string;
isConnected?: boolean;
};
}
).starknet;

if (!starknet) {
throw new Error('No Starknet wallet detected. Please install ArgentX or Braavos.');
}
).starknet;

if (!starknet) {
throw new Error('No Starknet wallet detected. Please install ArgentX or Braavos.');
}
const accounts = await starknet.enable();
const address = accounts[0] || starknet.selectedAddress;

const accounts = await starknet.enable();
const address = accounts[0] || starknet.selectedAddress;
if (!address) {
throw new Error('No account available');
}

if (!address) {
throw new Error('No account available');
setState((prev) => ({
...prev,
address,
isConnected: true,
isConnecting: false,
error: null,
}));
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to connect wallet';
setState((prev) => ({
...prev,
isConnecting: false,
error: message,
}));
console.error('[WalletProvider] Connection failed:', message);
throw error;
}

setState((prev) => ({
...prev,
address,
isConnected: true,
isConnecting: false,
error: null,
}));
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to connect wallet';
setState((prev) => ({
...prev,
isConnecting: false,
error: message,
}));
console.error('[WalletProvider] Connection failed:', message);
}
});
}, []);

const disconnect = useCallback(async () => {
Expand Down
Loading
Loading