diff --git a/src/hooks/__tests__/useWeb3Wallet.test.ts b/src/hooks/__tests__/useWeb3Wallet.test.ts index 5e991fce..a6154d51 100644 --- a/src/hooks/__tests__/useWeb3Wallet.test.ts +++ b/src/hooks/__tests__/useWeb3Wallet.test.ts @@ -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; @@ -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; @@ -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((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); + }); }); diff --git a/src/hooks/useWeb3Wallet.ts b/src/hooks/useWeb3Wallet.ts index 465ba438..fb30d082 100644 --- a/src/hooks/useWeb3Wallet.ts +++ b/src/hooks/useWeb3Wallet.ts @@ -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], ); @@ -535,5 +540,6 @@ export function useWeb3Wallet() { fetchBalance, clearError, supportedChains: SUPPORTED_CHAINS, + queueStats: walletConnectionQueue.getStats(), }; } diff --git a/src/providers/WalletProvider.tsx b/src/providers/WalletProvider.tsx index 56958706..f56fec91 100644 --- a/src/providers/WalletProvider.tsx +++ b/src/providers/WalletProvider.tsx @@ -8,6 +8,7 @@ import React, { useEffect, ReactNode, } from 'react'; +import { walletConnectionQueue } from '@/utils/web3/walletQueue'; // Environment validation for wallet config const validateWalletEnv = () => { @@ -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; - selectedAddress?: string; - isConnected?: boolean; - }; + const starknet = ( + window as Window & { + starknet?: { + enable: () => Promise; + 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 () => { diff --git a/src/utils/__tests__/walletQueue.test.ts b/src/utils/__tests__/walletQueue.test.ts new file mode 100644 index 00000000..8c039cf1 --- /dev/null +++ b/src/utils/__tests__/walletQueue.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WalletConnectionQueue } from '@/utils/web3/walletQueue'; + +describe('WalletConnectionQueue', () => { + let queue: WalletConnectionQueue; + + beforeEach(() => { + queue = new WalletConnectionQueue(); + }); + + it('should initialise with empty state', () => { + const stats = queue.getStats(); + expect(stats.queueLength).toBe(0); + expect(stats.isProcessing).toBe(false); + expect(stats.totalProcessed).toBe(0); + expect(stats.totalFailed).toBe(0); + }); + + it('should resolve a single operation', async () => { + const result = await queue.enqueue(() => Promise.resolve('ok')); + expect(result).toBe('ok'); + expect(queue.getStats().totalProcessed).toBe(1); + expect(queue.getStats().totalFailed).toBe(0); + }); + + it('should reject a failing operation and propagate the error', async () => { + await expect( + queue.enqueue(() => Promise.reject(new Error('wallet error'))), + ).rejects.toThrow('wallet error'); + expect(queue.getStats().totalFailed).toBe(1); + expect(queue.getStats().totalProcessed).toBe(0); + }); + + it('should serialise concurrent operations (FIFO)', async () => { + const order: number[] = []; + + const op = (id: number, ms: number) => + queue.enqueue( + () => + new Promise((resolve) => { + setTimeout(() => { + order.push(id); + resolve(`op-${id}`); + }, ms); + }), + ); + + const [r1, r2, r3] = await Promise.all([op(1, 30), op(2, 10), op(3, 5)]); + + expect(r1).toBe('op-1'); + expect(r2).toBe('op-2'); + expect(r3).toBe('op-3'); + expect(order).toEqual([1, 2, 3]); + expect(queue.getStats().totalProcessed).toBe(3); + }); + + it('should continue draining after a failure', async () => { + const results: string[] = []; + const errors: string[] = []; + + await Promise.allSettled([ + queue.enqueue(() => Promise.reject(new Error('fail'))).catch((e: Error) => errors.push(e.message)), + queue.enqueue(() => Promise.resolve('second')).then((v) => results.push(v)), + ]); + + expect(errors).toEqual(['fail']); + expect(results).toEqual(['second']); + expect(queue.getStats().totalFailed).toBe(1); + expect(queue.getStats().totalProcessed).toBe(1); + }); + + it('should reject pending operations when clear() is called', async () => { + let resolver!: (v: string) => void; + const blocker = new Promise((res) => { + resolver = res; + }); + + const first = queue.enqueue(() => blocker); + const second = queue.enqueue(() => Promise.resolve('should not run')); + + const secondResult = second.catch((e: Error) => e.message); + + queue.clear('Queue cleared'); + + resolver('done'); + await first; + + const msg = await secondResult; + expect(msg).toBe('Queue cleared'); + }); + + it('should report correct queue length while processing', async () => { + let releaseFirst!: (v: string) => void; + const firstPending = new Promise((res) => { + releaseFirst = res; + }); + + queue.enqueue(() => firstPending); + queue.enqueue(() => Promise.resolve('b')); + queue.enqueue(() => Promise.resolve('c')); + + await Promise.resolve(); + + expect(queue.length).toBe(2); + expect(queue.processing).toBe(true); + + releaseFirst('a'); + }); + + it('length getter returns zero after all operations settle', async () => { + await queue.enqueue(() => Promise.resolve('x')); + await queue.enqueue(() => Promise.resolve('y')); + expect(queue.length).toBe(0); + expect(queue.processing).toBe(false); + }); +}); diff --git a/src/utils/web3/index.ts b/src/utils/web3/index.ts index 786ad1a3..058e081e 100644 --- a/src/utils/web3/index.ts +++ b/src/utils/web3/index.ts @@ -1,8 +1,8 @@ -export * from './envValidation'; /** * Web3 Utilities * Barrel export for web3-related utilities */ +export * from './envValidation'; export { validateWeb3Env, @@ -16,6 +16,14 @@ export { validateWalletInteraction, type WalletInteractionResult } from './walle export { WalletCache, walletCache, walletCacheKeys, CACHE_TTL } from './walletCache'; +export { + WalletConnectionQueue, + walletConnectionQueue, + type QueuedOperation, + type QueueEntry, + type WalletQueueStats, +} from './walletQueue'; + export { isValidEthereumAddress, isValidStarknetAddress, diff --git a/src/utils/web3/walletQueue.ts b/src/utils/web3/walletQueue.ts new file mode 100644 index 00000000..c76b2f8f --- /dev/null +++ b/src/utils/web3/walletQueue.ts @@ -0,0 +1,105 @@ +/** + * Wallet Connection Queue Management + * + * Serialises concurrent wallet connection requests so that only one + * connection attempt runs at a time. Subsequent callers receive the + * same Promise as the in-flight attempt, and the queue drains FIFO. + * + * Prevents the race condition where rapid successive `connect()` calls + * would fire multiple wallet pop-ups and produce conflicting state updates. + */ + +export type QueuedOperation = () => Promise; + +export interface QueueEntry { + operation: QueuedOperation; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} + +export interface WalletQueueStats { + queueLength: number; + isProcessing: boolean; + totalProcessed: number; + totalFailed: number; +} + +export class WalletConnectionQueue { + private queue: QueueEntry[] = []; + private isProcessing = false; + private totalProcessed = 0; + private totalFailed = 0; + + /** + * Enqueue an operation. Returns a Promise that resolves / rejects when + * the operation eventually runs and settles. + */ + enqueue(operation: QueuedOperation): Promise { + return new Promise((resolve, reject) => { + this.queue.push({ operation, resolve, reject }); + this.drain(); + }); + } + + /** Number of operations waiting (excluding any currently running). */ + get length(): number { + return this.queue.length; + } + + /** True while an operation is actively running. */ + get processing(): boolean { + return this.isProcessing; + } + + /** Returns a snapshot of runtime statistics. */ + getStats(): WalletQueueStats { + return { + queueLength: this.queue.length, + isProcessing: this.isProcessing, + totalProcessed: this.totalProcessed, + totalFailed: this.totalFailed, + }; + } + + /** + * Discard all pending (not yet started) operations, rejecting their + * Promises with the supplied reason. + */ + clear(reason: string = 'Queue cleared'): void { + const pending = this.queue.splice(0); + for (const entry of pending) { + entry.reject(new Error(reason)); + } + } + + private drain(): void { + if (this.isProcessing || this.queue.length === 0) return; + + const entry = this.queue.shift(); + if (!entry) return; + + this.isProcessing = true; + + entry.operation().then( + (value) => { + this.totalProcessed++; + entry.resolve(value); + this.isProcessing = false; + this.drain(); + }, + (error: unknown) => { + this.totalFailed++; + entry.reject(error); + this.isProcessing = false; + this.drain(); + }, + ); + } +} + +/** + * Shared singleton queue for all wallet connection operations. + * Using a singleton ensures that even if multiple hook instances are + * mounted concurrently they all share the same serialisation lock. + */ +export const walletConnectionQueue = new WalletConnectionQueue();