import { describe, it, expect, beforeEach, vi } from 'vitest' import type { SessionTypes } from '@walletconnect/types' import { NetworkSwitchService } from './network-switch-service' import type { ConnectionService } from './connection-service' import type { WalletConnectClient } from './walletconnect-client' import { polygonNetwork } from '@meshconnect/uwc-constants' interface NamespaceShape { chains: string[] accounts: string[] methods: string[] events: string[] } function buildSession( namespaces: Record ): SessionTypes.Struct { return { topic: 'test-topic', pairingTopic: 'pairing-topic', relay: { protocol: 'irn' }, expiry: Math.floor(Date.now() / 1000) + 86400, acknowledged: true, controller: 'test-controller', requiredNamespaces: {}, optionalNamespaces: {}, sessionProperties: undefined, self: { publicKey: 'self-key', metadata: { name: 'dApp', description: '', url: '', icons: [] } }, peer: { publicKey: 'peer-key', metadata: { name: 'Wallet', description: '', url: '', icons: [] } }, namespaces } as unknown as SessionTypes.Struct } describe('NetworkSwitchService', () => { let service: NetworkSwitchService let mockEmit: ReturnType let mockGetSession: ReturnType let mockSetActiveNetwork: ReturnType let mockSetActiveAddress: ReturnType beforeEach(() => { vi.clearAllMocks() mockEmit = vi.fn().mockResolvedValue(undefined) mockGetSession = vi.fn() mockSetActiveNetwork = vi.fn() mockSetActiveAddress = vi.fn() const mockWcClient = { getClient: vi.fn().mockResolvedValue({ emit: mockEmit }) } as unknown as WalletConnectClient const mockConnectionService = { getSession: mockGetSession, setActiveNetwork: mockSetActiveNetwork, setActiveAddress: mockSetActiveAddress } as unknown as ConnectionService service = new NetworkSwitchService(mockWcClient, mockConnectionService) }) describe('switchNetwork', () => { it('succeeds when chain is in chains AND there is a matching account (spec-compliant wallets)', async () => { mockGetSession.mockReturnValue( buildSession({ eip155: { chains: ['eip155:1', 'eip155:137'], accounts: [ 'eip155:1:0xabc0000000000000000000000000000000000001', 'eip155:137:0xabc0000000000000000000000000000000000001' ], methods: ['eth_sendTransaction'], events: ['chainChanged'] } }) ) const result = await service.switchNetwork(polygonNetwork) expect(result).toEqual({ networkId: 'eip155:137', address: '0xabc0000000000000000000000000000000000001' }) expect(mockSetActiveNetwork).toHaveBeenCalledWith(polygonNetwork) expect(mockSetActiveAddress).toHaveBeenCalledWith( '0xabc0000000000000000000000000000000000001' ) expect(mockEmit).toHaveBeenCalledWith( expect.objectContaining({ chainId: 'eip155:137', event: { name: 'chainChanged', data: 'eip155:137' } }) ) }) // ZenGo's WC v2 quirk: // approval namespace ships with `chains: []` even though `accounts` covers // every requested chain. Per WC v2 sign-namespaces spec the wallet SHOULD // populate `chains`, but `accounts` already encodes (chainId:address) so // it's the source of truth. UWC must accept this shape — ZenGo users were // otherwise unable to switch to any chain in their session. // Source: live console capture, 2026-04-17, react-example + ZenGo iOS // peer.metadata.name: "Zengo Wallet" it('succeeds when chains is empty but accounts cover the requested chain (ZenGo WC v2 quirk)', async () => { mockGetSession.mockReturnValue( buildSession({ eip155: { chains: [], accounts: [ 'eip155:1:0xabc0000000000000000000000000000000000002', 'eip155:137:0xabc0000000000000000000000000000000000002', 'eip155:8453:0xabc0000000000000000000000000000000000002' ], methods: ['eth_sendTransaction'], events: ['chainChanged'] } }) ) const result = await service.switchNetwork(polygonNetwork) expect(result).toEqual({ networkId: 'eip155:137', address: '0xabc0000000000000000000000000000000000002' }) expect(mockSetActiveNetwork).toHaveBeenCalledWith(polygonNetwork) }) it('throws when chain is in neither chains nor accounts', async () => { mockGetSession.mockReturnValue( buildSession({ eip155: { chains: ['eip155:1'], accounts: ['eip155:1:0xabc0000000000000000000000000000000000003'], methods: ['eth_sendTransaction'], events: ['chainChanged'] } }) ) await expect(service.switchNetwork(polygonNetwork)).rejects.toThrow( /not available in the current session/ ) expect(mockSetActiveNetwork).not.toHaveBeenCalled() }) it('throws when there is no active session', async () => { mockGetSession.mockReturnValue(null) await expect(service.switchNetwork(polygonNetwork)).rejects.toThrow( /No active session/ ) }) it('throws when the eip155 namespace is missing entirely', async () => { mockGetSession.mockReturnValue(buildSession({})) await expect(service.switchNetwork(polygonNetwork)).rejects.toThrow( /not available in the current session/ ) }) // Inverse of the ZenGo quirk: if a wallet declares the chain in `chains` // but ships zero accounts for it, the session is unusable for that chain. // The first guard sees the chain in `chains` and lets it through, but the // second guard (find matching account) catches the inconsistency with a // clear message naming the chain. it('throws when chain is declared in chains but no account exists for it', async () => { mockGetSession.mockReturnValue( buildSession({ eip155: { chains: ['eip155:1', 'eip155:137'], accounts: ['eip155:1:0xabc0000000000000000000000000000000000004'], methods: ['eth_sendTransaction'], events: ['chainChanged'] } }) ) await expect(service.switchNetwork(polygonNetwork)).rejects.toThrow( /No account found for network eip155:137/ ) expect(mockSetActiveNetwork).not.toHaveBeenCalled() }) }) })