import { describe, it, expect, beforeEach, vi } from 'vitest' import { EthereumWalletService } from './ethereum-wallet-service' import type { EthereumProvider } from '../../eip6963-discovery' import type { Network, WalletLifecycleEvent } from '@meshconnect/uwc-types' // Mock the eip6963-discovery module vi.mock('../../eip6963-discovery', () => ({ getAvailableWallets: vi.fn() })) describe('EthereumWalletService', () => { let service: EthereumWalletService let mockProvider: EthereumProvider beforeEach(() => { service = new EthereumWalletService() mockProvider = { request: vi.fn() } as any }) describe('initializeDiscovery', () => { it('should fetch and store detected wallets', async () => { const { getAvailableWallets } = await import('../../eip6963-discovery') const mockWallets = [ { uuid: 'wallet-1', name: 'MetaMask', icon: 'icon-url', provider: mockProvider } ] vi.mocked(getAvailableWallets).mockResolvedValue(mockWallets as any) await service.initializeDiscovery() expect(service.getDetectedWallets()).toEqual(mockWallets) }) }) describe('getDetectedWallets', () => { it('should return empty array initially', () => { expect(service.getDetectedWallets()).toEqual([]) }) }) describe('findWalletByUuid', () => { it('should find wallet by uuid', async () => { const { getAvailableWallets } = await import('../../eip6963-discovery') const mockWallets = [ { uuid: 'wallet-1', name: 'Wallet 1', provider: mockProvider }, { uuid: 'wallet-2', name: 'Wallet 2', provider: mockProvider } ] vi.mocked(getAvailableWallets).mockResolvedValue(mockWallets as any) await service.initializeDiscovery() const wallet = service.findWalletByUuid('wallet-2') expect(wallet).toEqual(mockWallets[1]) }) it('should return undefined for non-existent uuid', async () => { const wallet = service.findWalletByUuid('non-existent') expect(wallet).toBeUndefined() }) }) describe('checkExistingConnection', () => { it('should return accounts if already connected', async () => { const accounts = ['0x123', '0x456'] vi.mocked(mockProvider.request).mockResolvedValue(accounts) const result = await service.checkExistingConnection(mockProvider) expect(mockProvider.request).toHaveBeenCalledWith({ method: 'eth_accounts' }) expect(result).toEqual(accounts) }) it('should return null if no accounts connected', async () => { vi.mocked(mockProvider.request).mockResolvedValue([]) const result = await service.checkExistingConnection(mockProvider) expect(result).toBeNull() }) it('should return null on error', async () => { vi.mocked(mockProvider.request).mockRejectedValue( new Error('User rejected') ) const result = await service.checkExistingConnection(mockProvider) expect(result).toBeNull() }) }) describe('connect', () => { it('should connect successfully and return address', async () => { const accounts = ['0xabcdef123456'] vi.mocked(mockProvider.request) .mockResolvedValueOnce(accounts) // eth_requestAccounts .mockResolvedValueOnce('0x1') // eth_chainId const address = await service.connect(mockProvider) expect(mockProvider.request).toHaveBeenCalledWith({ method: 'eth_requestAccounts' }) expect(mockProvider.request).toHaveBeenCalledWith({ method: 'eth_chainId' }) expect(address).toBe(accounts[0]) expect(service.getAccount()).toBe(accounts[0]) expect(service.getConnectedProvider()).toBe(mockProvider) }) it('should throw error if no accounts returned', async () => { vi.mocked(mockProvider.request).mockResolvedValue([]) await expect(service.connect(mockProvider)).rejects.toThrow( 'No accounts returned from wallet' ) }) it('should throw error if accounts is null', async () => { vi.mocked(mockProvider.request).mockResolvedValue(null) await expect(service.connect(mockProvider)).rejects.toThrow( 'No accounts returned from wallet' ) }) }) describe('switchNetwork', () => { beforeEach(async () => { // Connect first vi.mocked(mockProvider.request) .mockResolvedValueOnce(['0x123']) .mockResolvedValueOnce('0x1') await service.connect(mockProvider) vi.clearAllMocks() }) it('should switch network successfully', async () => { vi.mocked(mockProvider.request).mockResolvedValue(null) await service.switchNetwork('eip155:1') expect(mockProvider.request).toHaveBeenCalledWith({ method: 'wallet_switchEthereumChain', params: [{ chainId: '0x1' }] }) }) it('should convert decimal chain ID to hex', async () => { vi.mocked(mockProvider.request).mockResolvedValue(null) await service.switchNetwork('eip155:137') expect(mockProvider.request).toHaveBeenCalledWith({ method: 'wallet_switchEthereumChain', params: [{ chainId: '0x89' }] }) }) it('should throw error if not connected', async () => { const disconnectedService = new EthereumWalletService() await expect( disconnectedService.switchNetwork('eip155:1') ).rejects.toThrow('No Ethereum wallet connected') }) it('should throw error for invalid network ID format', async () => { await expect(service.switchNetwork('invalid-format')).rejects.toThrow( 'Invalid network ID format: invalid-format' ) }) it('should throw error for missing chain ID', async () => { await expect(service.switchNetwork('eip155:')).rejects.toThrow( 'Invalid network ID format: eip155:' ) }) it('should handle chain not added error (code 4902)', async () => { const error = { code: 4902, message: 'Chain not added' } vi.mocked(mockProvider.request).mockRejectedValue(error) await expect(service.switchNetwork('eip155:999')).rejects.toThrow( 'Chain eip155:999 cannot be added to the wallet: Error: Chain not added' ) }) it('should rethrow other errors', async () => { const error = new Error('User rejected') vi.mocked(mockProvider.request).mockRejectedValue(error) await expect(service.switchNetwork('eip155:1')).rejects.toThrow( 'User rejected' ) }) }) describe('buildAvailableAddresses', () => { it('should build addresses for EVM networks', () => { const supportedNetworkIds = ['eip155:1', 'eip155:137', 'eip155:8453'] const address = '0x123456' const result = service.buildAvailableAddresses( supportedNetworkIds, address ) expect(result).toEqual([ { address: '0x123456', networkId: 'eip155:1' }, { address: '0x123456', networkId: 'eip155:137' }, { address: '0x123456', networkId: 'eip155:8453' } ]) }) it('should filter out non-EVM networks', () => { const supportedNetworkIds = ['eip155:1', 'solana:mainnet', 'eip155:137'] const address = '0x123456' const result = service.buildAvailableAddresses( supportedNetworkIds, address ) expect(result).toEqual([ { address: '0x123456', networkId: 'eip155:1' }, { address: '0x123456', networkId: 'eip155:137' } ]) }) it('should return empty array for no EVM networks', () => { const supportedNetworkIds = ['solana:mainnet', 'cosmos:hub'] const address = '0x123456' const result = service.buildAvailableAddresses( supportedNetworkIds, address ) expect(result).toEqual([]) }) }) describe('setConnectionState', () => { it('should update connection state', () => { service.setConnectionState(mockProvider, '0xabc') expect(service.getConnectedProvider()).toBe(mockProvider) expect(service.getAccount()).toBe('0xabc') }) it('should allow clearing state', () => { service.setConnectionState(mockProvider, '0xabc') service.setConnectionState(null, null) expect(service.getConnectedProvider()).toBeNull() expect(service.getAccount()).toBeNull() }) }) describe('disconnect', () => { it('should clear connection state', async () => { // Connect first vi.mocked(mockProvider.request) .mockResolvedValueOnce(['0x123']) .mockResolvedValueOnce('0x1') await service.connect(mockProvider) service.disconnect() expect(service.getConnectedProvider()).toBeNull() expect(service.getAccount()).toBeNull() }) }) describe('getCapabilities', () => { let networks: Network[] beforeEach(async () => { // Connect first vi.mocked(mockProvider.request) .mockResolvedValueOnce(['0x123']) .mockResolvedValueOnce('0x1') await service.connect(mockProvider) vi.clearAllMocks() networks = [ { id: 'eip155:1', internalId: 1, name: 'Ethereum Mainnet', namespace: 'eip155', networkType: 'evm', nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 } }, { id: 'eip155:137', internalId: 137, name: 'Polygon', namespace: 'eip155', networkType: 'evm', nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 } } ] as Network[] }) it('should fetch and parse wallet capabilities with supported field', async () => { const rawCapabilities = { '0x1': { paymasterService: { supported: true }, sessionKeys: { supported: false } }, '0x89': { paymasterService: { supported: false } } } vi.mocked(mockProvider.request).mockResolvedValue(rawCapabilities) const result = await service.getCapabilities('0x123', networks) expect(mockProvider.request).toHaveBeenCalledWith({ method: 'wallet_getCapabilities', params: ['0x123', ['0x1', '0x89']] }) expect(result).toEqual({ 'eip155:1': { paymasterService: { status: 'supported' }, sessionKeys: { status: 'unsupported' } }, 'eip155:137': { paymasterService: { status: 'unsupported' } } }) }) it('should handle capabilities with status field', async () => { const rawCapabilities = { '0x1': { paymasterService: { status: 'ready' }, sessionKeys: { status: 'supported' } } } vi.mocked(mockProvider.request).mockResolvedValue(rawCapabilities) const result = await service.getCapabilities('0x123', networks) expect(result).toEqual({ 'eip155:1': { paymasterService: { status: 'ready' }, sessionKeys: { status: 'supported' } } }) }) it('should filter out non-EVM networks', async () => { const mixedNetworks = [ ...networks, { id: 'solana:mainnet', internalId: 1, name: 'Solana', namespace: 'solana', networkType: 'solana' } ] as Network[] const rawCapabilities = { '0x1': { paymasterService: { supported: true } } } vi.mocked(mockProvider.request).mockResolvedValue(rawCapabilities) await service.getCapabilities('0x123', mixedNetworks) expect(mockProvider.request).toHaveBeenCalledWith({ method: 'wallet_getCapabilities', params: ['0x123', ['0x1', '0x89']] }) }) it('should return empty object on error', async () => { vi.mocked(mockProvider.request).mockRejectedValue( new Error('Method not supported') ) const result = await service.getCapabilities('0x123', networks) expect(result).toEqual({}) }) it('should throw error if not connected', async () => { const disconnectedService = new EthereumWalletService() await expect( disconnectedService.getCapabilities('0x123', networks) ).rejects.toThrow('No Ethereum wallet connected') }) it('should handle invalid capability values', async () => { const rawCapabilities = { '0x1': { paymasterService: { supported: true }, invalidCap1: 'string-value', invalidCap2: 123, invalidCap3: { someOtherField: true }, invalidCap4: { status: 'invalid-status' } } } vi.mocked(mockProvider.request).mockResolvedValue(rawCapabilities) const result = await service.getCapabilities('0x123', networks) expect(result).toEqual({ 'eip155:1': { paymasterService: { status: 'supported' } } }) }) it('should handle empty capabilities response', async () => { vi.mocked(mockProvider.request).mockResolvedValue({}) const result = await service.getCapabilities('0x123', networks) expect(result).toEqual({}) }) it('should handle null capabilities response', async () => { vi.mocked(mockProvider.request).mockResolvedValue(null) const result = await service.getCapabilities('0x123', networks) expect(result).toEqual({}) }) it('should convert hex chain IDs correctly', async () => { const rawCapabilities = { '0xa': { // Chain ID 10 (Optimism) paymasterService: { supported: true } }, '0xff': { // Chain ID 255 sessionKeys: { supported: true } } } vi.mocked(mockProvider.request).mockResolvedValue(rawCapabilities) const result = await service.getCapabilities('0x123', networks) expect(result['eip155:10']).toEqual({ paymasterService: { status: 'supported' } }) expect(result['eip155:255']).toEqual({ sessionKeys: { status: 'supported' } }) }) }) describe('onLifecycleEvent', () => { function providerWithEvents() { const handlers: Record void> = {} const provider = { request: vi.fn(), on: (event: string, h: (arg?: unknown) => void) => { handlers[event] = h }, removeListener: vi.fn((event: string) => { delete handlers[event] }) } as unknown as EthereumProvider return { provider, handlers } } it('returns undefined when there is no connected provider', () => { expect(service.onLifecycleEvent(() => {})).toBeUndefined() }) it('returns undefined when the connected provider exposes no .on', () => { service['connectedProvider'] = { request: vi.fn() } as unknown as EthereumProvider expect(service.onLifecycleEvent(() => {})).toBeUndefined() }) it('returns undefined when the provider has .on but no .removeListener (would leak on teardown)', () => { // EIP-1193 lists removeListener as SHOULD, not MUST. Attaching listeners // we can never remove IS the zombie-listener class this layer targets — // never half-subscribe. const on = vi.fn() service['connectedProvider'] = { request: vi.fn(), on } as unknown as EthereumProvider expect(service.onLifecycleEvent(() => {})).toBeUndefined() expect(on).not.toHaveBeenCalled() }) it('returns undefined in iframe bridge mode (the provider is a Comlink proxy)', () => { const { provider } = providerWithEvents() service['connectedProvider'] = provider // The bridge child sets this flag when UWC runs inside the Link iframe; a // raw EIP-1193 handler can't ride the bridge, so we must NOT subscribe. ;( window as unknown as { UWCBridgeChildInitialized?: boolean } ).UWCBridgeChildInitialized = true try { expect(service.onLifecycleEvent(() => {})).toBeUndefined() } finally { delete (window as unknown as { UWCBridgeChildInitialized?: boolean }) .UWCBridgeChildInitialized } }) it('maps events, drops the address, and converts the chain id to CAIP-2', () => { const { provider, handlers } = providerWithEvents() service['connectedProvider'] = provider const events: WalletLifecycleEvent[] = [] const stop = service.onLifecycleEvent(e => events.push(e)) expect(typeof stop).toBe('function') handlers.accountsChanged!(['0xNEWADDRESS']) handlers.accountsChanged!([]) // empty list = disconnected handlers.chainChanged!('0x2105') // 8453 (Base) handlers.disconnect!() expect(events).toEqual([ { type: 'accountChanged' }, { type: 'disconnect' }, { type: 'chainChanged', chainId: 'eip155:8453' }, { type: 'disconnect' } ]) // The new address NEVER appears in an emitted event (PII boundary). expect(JSON.stringify(events)).not.toContain('0xNEWADDRESS') stop!() expect(provider.removeListener).toHaveBeenCalledTimes(3) }) it('emits chainChanged WITHOUT a chainId for malformed payloads — never a bogus CAIP id', () => { const { provider, handlers } = providerWithEvents() service['connectedProvider'] = provider const events: WalletLifecycleEvent[] = [] service.onLifecycleEvent(e => events.push(e)) handlers.chainChanged!('0x1junk') // parseInt(_, 16) would read 1 handlers.chainChanged!('12abc') // parseInt(_, 10) would read 12 handlers.chainChanged!('-5') // negative handlers.chainChanged!(1.5) // non-integer number handlers.chainChanged!(-1) // negative number handlers.chainChanged!('0x') // no digits handlers.chainChanged!('0x20000000000001') // 2^53+1: parseInt lost precision handlers.chainChanged!('9007199254740993') // 2^53+1 decimal: same handlers.chainChanged!('137') // valid decimal string handlers.chainChanged!(8453) // valid number expect(events).toEqual([ { type: 'chainChanged' }, { type: 'chainChanged' }, { type: 'chainChanged' }, { type: 'chainChanged' }, { type: 'chainChanged' }, { type: 'chainChanged' }, { type: 'chainChanged' }, { type: 'chainChanged' }, { type: 'chainChanged', chainId: 'eip155:137' }, { type: 'chainChanged', chainId: 'eip155:8453' } ]) }) }) })