import { describe, it, expect, vi } from 'vitest' import type { WalletConnectConfig, WalletLifecycleEvent } from '@meshconnect/uwc-types' import { WalletConnectConnector } from './wallet-connect-connector' const WC_CONTEXT = { connectionMode: 'walletConnect' as const } function mockSignClient() { const handlers: Record void> = {} return { on: (e: string, h: (a?: unknown) => void) => { handlers[e] = h }, off: vi.fn((e: string) => { delete handlers[e] }), fire: (e: string, arg?: unknown) => handlers[e]?.(arg) } } function makeConnector(client: unknown) { const connector = new WalletConnectConnector({ projectId: 'test' } as WalletConnectConfig) // Stub the post-connect client lookup (no real relay in a unit test). ;( connector as unknown as { walletConnectClient: { getExistingClient: () => unknown } } ).walletConnectClient.getExistingClient = () => client return connector } // The SignClient is client-global: its events fire for EVERY stored session // (WC v2 persists old sessions across page loads), so the lifecycle handlers // must filter by the ACTIVE session's topic. This helper plants the active // session the way `connect()` would. function setActiveTopic(connector: WalletConnectConnector, topic: string) { ;( connector as unknown as { connectionService: { setSession: (s: unknown) => void } } ).connectionService.setSession({ topic }) } describe('WalletConnectConnector.onLifecycleEvent', () => { it('clearSession forgets the removed topic without another SignClient disconnect', async () => { const client = mockSignClient() const connector = makeConnector(client) const disconnect = vi.spyOn(connector['walletConnectClient'], 'disconnect') const listener = vi.fn() setActiveTopic(connector, 'deleted-topic') connector['connectionService'].setActiveAddress('0xabc') const stop = connector.onLifecycleEvent(listener, WC_CONTEXT) connector.clearSession() connector.clearSession() await connector.disconnect() client.fire('session_delete', { topic: 'deleted-topic' }) client.fire('session_expire', { topic: 'deleted-topic' }) expect(connector['connectionService'].getSession()).toBeNull() expect(connector['connectionService'].getActiveAddress()).toBeNull() expect(disconnect).not.toHaveBeenCalled() expect(listener).not.toHaveBeenCalled() stop?.() }) it('returns undefined before a session exists', () => { expect( makeConnector(null).onLifecycleEvent(() => {}, WC_CONTEXT) ).toBeUndefined() }) it('maps session_delete / session_expire / session_event for the ACTIVE topic and drops the address', () => { const client = mockSignClient() const events: WalletLifecycleEvent[] = [] const connector = makeConnector(client) setActiveTopic(connector, 't-active') const stop = connector.onLifecycleEvent(e => events.push(e), WC_CONTEXT) expect(typeof stop).toBe('function') client.fire('session_delete', { id: 1, topic: 't-active' }) client.fire('session_expire', { topic: 't-active' }) // CAIP session_event: accountsChanged carries an address we must NOT forward. client.fire('session_event', { id: 2, topic: 't-active', params: { event: { name: 'accountsChanged', data: ['eip155:1:0xDEADBEEF'] }, chainId: 'eip155:1' } }) // Real WC payload shape: the NEW chain rides in `event.data` (EIP-1193 // convention); `params.chainId` is only the scope — here the PRE-switch // chain, which must NOT be reported. client.fire('session_event', { id: 3, topic: 't-active', params: { event: { name: 'chainChanged', data: '0x2105' }, chainId: 'eip155:1' } }) expect(events).toEqual([ { type: 'disconnect' }, { type: 'sessionExpired' }, { type: 'accountChanged' }, { type: 'chainChanged', chainId: 'eip155:8453' } ]) expect(JSON.stringify(events)).not.toContain('0xDEADBEEF') stop!() expect(client.off).toHaveBeenCalledTimes(3) }) it('drops lifecycle events from a NON-active topic (stale persisted session)', () => { // A stale session left in WC storage from a previous pairing expiring must // not surface as a phantom disconnect/expiry of the live session. const client = mockSignClient() const events: WalletLifecycleEvent[] = [] const connector = makeConnector(client) setActiveTopic(connector, 't-active') connector.onLifecycleEvent(e => events.push(e), WC_CONTEXT) client.fire('session_delete', { id: 1, topic: 't-stale' }) client.fire('session_expire', { topic: 't-stale' }) client.fire('session_event', { id: 2, topic: 't-stale', params: { event: { name: 'chainChanged' }, chainId: 'eip155:10' } }) expect(events).toEqual([]) }) it('drops all lifecycle events when no session is active', () => { // Post-disconnect (session cleared) the client can still emit for stored // sessions; with no active topic nothing may be forwarded. const client = mockSignClient() const events: WalletLifecycleEvent[] = [] makeConnector(client).onLifecycleEvent(e => events.push(e), WC_CONTEXT) client.fire('session_delete', { id: 1, topic: 't-any' }) client.fire('session_expire', { topic: 't-any' }) expect(events).toEqual([]) }) it('reads the active topic per event, so a re-connect is honoured live', () => { const client = mockSignClient() const events: WalletLifecycleEvent[] = [] const connector = makeConnector(client) setActiveTopic(connector, 't-1') connector.onLifecycleEvent(e => events.push(e), WC_CONTEXT) client.fire('session_expire', { topic: 't-2' }) expect(events).toEqual([]) // New session replaces the old one (restore / re-connect) — events for the // new topic must flow without re-subscribing. setActiveTopic(connector, 't-2') client.fire('session_expire', { topic: 't-2' }) expect(events).toEqual([{ type: 'sessionExpired' }]) }) it('normalizes chainChanged event.data variants to CAIP-2 and never falls back to the scope chain', () => { const client = mockSignClient() const events: WalletLifecycleEvent[] = [] const connector = makeConnector(client) setActiveTopic(connector, 't-active') connector.onLifecycleEvent(e => events.push(e), WC_CONTEXT) const fire = (data: unknown) => client.fire('session_event', { id: 1, topic: 't-active', params: { event: { name: 'chainChanged', data }, chainId: 'eip155:1' } }) fire('0x89') // hex string fire('137') // decimal string fire(8453) // number fire('eip155:10') // already CAIP-2 fire('eip155:0x2105') // CAIP-2 shape, but eip155 refs must be decimal fire('tvm:-239') // non-eip155 namespace: reference is opaque, verbatim // Malformed / missing / precision-lost data must yield a content-free // event, NOT the scope chain (`eip155:1` here is the PRE-switch chain). fire('0x1junk') fire('eip155:junk') // CAIP-2 shape, eip155 ref neither decimal nor hex fire(undefined) fire('0x20000000000001') // 2^53+1: parseInt lost precision // CAIP-2 shape boundaries — lock the validator's exact contract: fire(`solana:${'a'.repeat(32)}`) // reference at the 32-char cap: valid fire('ab:1') // namespace under 3 chars fire('abcdefghi:1') // namespace over 8 chars fire(`solana:${'a'.repeat(33)}`) // reference over the 32-char cap fire('EIP155:1') // namespace must be lowercase fire('tvm:-239:extra') // a second colon is not a CAIP-2 reference expect(events).toEqual([ { type: 'chainChanged', chainId: 'eip155:137' }, { type: 'chainChanged', chainId: 'eip155:137' }, { type: 'chainChanged', chainId: 'eip155:8453' }, { type: 'chainChanged', chainId: 'eip155:10' }, { type: 'chainChanged', chainId: 'eip155:8453' }, { type: 'chainChanged', chainId: 'tvm:-239' }, { type: 'chainChanged' }, { type: 'chainChanged' }, { type: 'chainChanged' }, { type: 'chainChanged' }, { type: 'chainChanged', chainId: `solana:${'a'.repeat(32)}` }, { type: 'chainChanged' }, { type: 'chainChanged' }, { type: 'chainChanged' }, { type: 'chainChanged' }, { type: 'chainChanged' } ]) }) }) describe('WalletConnectConnector.onAccountsChanged', () => { it('returns undefined before a session exists', () => { expect( makeConnector(null).onAccountsChanged!(() => {}, WC_CONTEXT) ).toBeUndefined() }) it('forwards the re-derived accounts for the ACTIVE topic on a session_event accountsChanged', () => { const client = mockSignClient() const connector = makeConnector(client) ;( connector as unknown as { connectionService: { setSession: (s: unknown) => void } } ).connectionService.setSession({ topic: 't-active', namespaces: { eip155: { accounts: ['eip155:1:0xNEW'] } } }) const seen: string[][] = [] connector.onAccountsChanged!(accounts => seen.push(accounts), WC_CONTEXT) client.fire('session_event', { id: 2, topic: 't-active', params: { event: { name: 'accountsChanged', data: ['eip155:1:0xNEW'] }, chainId: 'eip155:1' } }) expect(seen).toEqual([['0xNEW']]) }) it('ignores a session_event from a NON-active topic (stale persisted session)', () => { const client = mockSignClient() const connector = makeConnector(client) setActiveTopic(connector, 't-active') const seen: string[][] = [] connector.onAccountsChanged!(accounts => seen.push(accounts), WC_CONTEXT) client.fire('session_event', { topic: 't-stale', params: { event: { name: 'accountsChanged' }, chainId: 'eip155:1' } }) expect(seen).toEqual([]) }) it('ignores a session_event that is not accountsChanged', () => { const client = mockSignClient() const connector = makeConnector(client) setActiveTopic(connector, 't-active') const seen: string[][] = [] connector.onAccountsChanged!(accounts => seen.push(accounts), WC_CONTEXT) client.fire('session_event', { topic: 't-active', params: { event: { name: 'chainChanged', data: '0x1' }, chainId: 'eip155:1' } }) expect(seen).toEqual([]) }) it('teardown removes the session_event listener', () => { const client = mockSignClient() const connector = makeConnector(client) setActiveTopic(connector, 't-active') const stop = connector.onAccountsChanged!(() => {}, WC_CONTEXT) stop!() expect(client.off).toHaveBeenCalledTimes(1) expect(client.off.mock.calls[0]![0]).toBe('session_event') }) it('ignores a session_event for a namespace the caller did NOT ask this subscription to observe (multi-namespace session)', () => { // One WC session/topic can carry multiple approved namespaces at once // (e.g. eip155 + solana). Core attaches ONE subscription per observed // namespace via `context.namespace` — an accountsChanged firing for a // DIFFERENT namespace on the SAME topic must not reach this listener, // or the caller (bound to eip155) would receive solana addresses. const client = mockSignClient() const connector = makeConnector(client) ;( connector as unknown as { connectionService: { setSession: (s: unknown) => void } } ).connectionService.setSession({ topic: 't-active', namespaces: { eip155: { accounts: ['eip155:1:0xEVM'] }, solana: { accounts: ['solana:mainnet:SoLaNaAddr'] } } }) const seen: string[][] = [] connector.onAccountsChanged!(accounts => seen.push(accounts), { connectionMode: 'walletConnect', namespace: 'eip155' }) // The wallet's SOLANA leg reports its own accountsChanged on the SAME topic. client.fire('session_event', { topic: 't-active', params: { event: { name: 'accountsChanged', data: ['solana:mainnet:SoLaNaAddr'] }, chainId: 'solana:mainnet' } }) expect(seen).toEqual([]) }) }) describe('WalletConnectConnector.beginPairing', () => { it('forwards the provider to the connection service and returns its URI', async () => { const connector = makeConnector(null) const beginPairing = vi.fn().mockResolvedValue('wc:delegated@2') ;( connector as unknown as { connectionService: { beginPairing: unknown } } ).connectionService.beginPairing = beginPairing const provider = { supportedNetworkIds: ['eip155:1'] } const uri = await connector.beginPairing(provider) expect(uri).toBe('wc:delegated@2') expect(beginPairing).toHaveBeenCalledWith(provider) }) }) describe('WalletConnectConnector telemetry constructor (0.8.x back-compat)', () => { // Reach into the private client to prove the constructor normalized whatever // form it received into a single telemetryObserver (no relay contact here). const observerOf = (c: WalletConnectConnector) => ( c as unknown as { walletConnectClient: { telemetryObserver: Record } } ).walletConnectClient.telemetryObserver it('forwards the new observer object form', () => { const onRelayDisconnect = () => {} const c = new WalletConnectConnector( { projectId: 'p' } as WalletConnectConfig, {}, { onRelayDisconnect } ) expect(observerOf(c).onRelayDisconnect).toBe(onRelayDisconnect) }) it('forwards the legacy positional (onRelayError, onInitError) form', () => { const onRelayError = () => {} const onInitError = () => {} // Legacy 0.8.x call site: (config, networkRpcMap, onRelayError, onInitError). const c = new WalletConnectConnector( { projectId: 'p' } as WalletConnectConfig, {}, onRelayError, onInitError ) expect(observerOf(c).onRelayError).toBe(onRelayError) expect(observerOf(c).onInitError).toBe(onInitError) }) }) // ONC-1975: WalletConnect always hands off to the wallet, so it cannot keep the // silentOnly never-prompt promise. A plain Error, before the connection service // starts a pairing. describe('WalletConnectConnector.connect silentOnly', () => { it('rejects silentOnly before starting a pairing', async () => { const connector = makeConnector(mockSignClient()) const start = vi.spyOn( ( connector as unknown as { connectionService: { connect: (...a: unknown[]) => unknown } } ).connectionService, 'connect' ) await expect( connector.connect( { id: 'eip155:1', namespace: 'eip155' } as never, { supportedNetworkIds: ['eip155:1'] } as never, { silentOnly: true } ) ).rejects.toThrow( 'silentOnly is not supported by the WalletConnect connector' ) expect(start).not.toHaveBeenCalled() }) })