From 1030ee9456faff6056d58880ef471c98a8d16ee3 Mon Sep 17 00:00:00 2001 From: Itodo-S Date: Sun, 26 Apr 2026 01:06:02 +0100 Subject: [PATCH] feat: implement WalletConnect v2 multi-chain support and connection health (#227) --- .../__tests__/connectionHealth.test.ts | 81 +++++++++++++++++ .../__tests__/multiChain.test.ts | 87 +++++++++++++++++++ .../walletconnect/connectionHealth.ts | 50 +++++++++++ src/services/walletconnect/multiChain.ts | 52 +++++++++++ 4 files changed, 270 insertions(+) create mode 100644 src/services/walletconnect/__tests__/connectionHealth.test.ts create mode 100644 src/services/walletconnect/__tests__/multiChain.test.ts create mode 100644 src/services/walletconnect/connectionHealth.ts create mode 100644 src/services/walletconnect/multiChain.ts diff --git a/src/services/walletconnect/__tests__/connectionHealth.test.ts b/src/services/walletconnect/__tests__/connectionHealth.test.ts new file mode 100644 index 0000000..d22f705 --- /dev/null +++ b/src/services/walletconnect/__tests__/connectionHealth.test.ts @@ -0,0 +1,81 @@ +import { assessConnectionHealth, formatConnectionDuration } from '../connectionHealth'; +import type { WalletConnectSessionState } from '../types'; + +function makeSession(overrides: Partial = {}): WalletConnectSessionState { + const now = new Date().toISOString(); + return { + status: 'connected', + address: '0xabc123', + chainId: 1, + supportedChainIds: [1, 137], + connectedAt: now, + lastUpdatedAt: now, + pairingUri: 'subtrackr://walletconnect?payload=test', + sessionTopic: 'wc-v2:1:0xabc123', + lastError: null, + disconnectReason: null, + ...overrides, + }; +} + +describe('assessConnectionHealth', () => { + it('returns healthy for a fully populated connected session', () => { + const health = assessConnectionHealth(makeSession()); + expect(health.status).toBe('healthy'); + expect(health.issues).toHaveLength(0); + }); + + it('returns disconnected when session status is not connected', () => { + const health = assessConnectionHealth(makeSession({ status: 'disconnected' })); + expect(health.status).toBe('disconnected'); + expect(health.issues.some((i) => i.includes('session_status'))).toBe(true); + }); + + it('returns disconnected for idle session', () => { + const health = assessConnectionHealth(makeSession({ status: 'idle' })); + expect(health.status).toBe('disconnected'); + }); + + it('reports missing_address issue', () => { + const health = assessConnectionHealth(makeSession({ address: null })); + expect(health.issues).toContain('missing_address'); + }); + + it('reports missing_chain_id issue', () => { + const health = assessConnectionHealth(makeSession({ chainId: null })); + expect(health.issues).toContain('missing_chain_id'); + }); + + it('reports session_stale when lastUpdatedAt is old', () => { + const staleTime = new Date(Date.now() - 10 * 60 * 1000).toISOString(); // 10 min ago + const health = assessConnectionHealth(makeSession({ lastUpdatedAt: staleTime })); + expect(health.issues).toContain('session_stale'); + expect(health.status).toBe('degraded'); + }); + + it('calculates connectedDurationMs when connectedAt is set', () => { + const connectedAt = new Date(Date.now() - 30_000).toISOString(); + const health = assessConnectionHealth(makeSession({ connectedAt })); + expect(health.connectedDurationMs).not.toBeNull(); + expect(health.connectedDurationMs!).toBeGreaterThan(0); + }); + + it('sets connectedDurationMs to null when connectedAt is null', () => { + const health = assessConnectionHealth(makeSession({ connectedAt: null })); + expect(health.connectedDurationMs).toBeNull(); + }); +}); + +describe('formatConnectionDuration', () => { + it('formats seconds for short durations', () => { + expect(formatConnectionDuration(45_000)).toBe('45s'); + }); + + it('formats minutes', () => { + expect(formatConnectionDuration(3 * 60_000)).toBe('3m'); + }); + + it('formats hours', () => { + expect(formatConnectionDuration(2 * 3_600_000)).toBe('2h'); + }); +}); diff --git a/src/services/walletconnect/__tests__/multiChain.test.ts b/src/services/walletconnect/__tests__/multiChain.test.ts new file mode 100644 index 0000000..e3b9dc2 --- /dev/null +++ b/src/services/walletconnect/__tests__/multiChain.test.ts @@ -0,0 +1,87 @@ +import { WALLETCONNECT_CHAINS } from '../chains'; +import { + buildMultiChainState, + getActiveChain, + getCaipNetworkId, + isChainSupported, + switchChain, +} from '../multiChain'; + +const SUPPORTED_IDS = WALLETCONNECT_CHAINS.map((c) => c.chainId); + +describe('WalletConnect v2 — multi-chain support', () => { + describe('buildMultiChainState', () => { + it('sets activeChainId from argument', () => { + const state = buildMultiChainState(137); + expect(state.activeChainId).toBe(137); + }); + + it('includes all supported chain IDs', () => { + const state = buildMultiChainState(1); + expect(state.supportedChainIds).toEqual(expect.arrayContaining(SUPPORTED_IDS)); + }); + + it('exposes chain metadata array', () => { + const state = buildMultiChainState(1); + expect(state.chains.length).toBe(WALLETCONNECT_CHAINS.length); + expect(state.chains[0]).toHaveProperty('caipNetworkId'); + }); + }); + + describe('switchChain', () => { + it('succeeds for a supported chain', () => { + const state = buildMultiChainState(1); + const result = switchChain(state, 137); + expect(result.success).toBe(true); + expect(result.chainId).toBe(137); + expect(result.chain?.name).toBe('Polygon'); + }); + + it('fails for an unsupported chain ID', () => { + const state = buildMultiChainState(1); + const result = switchChain(state, 99999); + expect(result.success).toBe(false); + expect(result.error).toContain('chain_not_supported'); + expect(result.chainId).toBe(1); // stays on current + }); + + it('switching to current chain still succeeds', () => { + const state = buildMultiChainState(1); + const result = switchChain(state, 1); + expect(result.success).toBe(true); + }); + }); + + describe('getActiveChain', () => { + it('returns chain metadata for active chain', () => { + const state = buildMultiChainState(8453); + const chain = getActiveChain(state); + expect(chain?.name).toBe('Base'); + expect(chain?.caipNetworkId).toBe('eip155:8453'); + }); + }); + + describe('isChainSupported', () => { + it('returns true for all configured chains', () => { + SUPPORTED_IDS.forEach((id) => expect(isChainSupported(id)).toBe(true)); + }); + + it('returns false for unknown chain', () => { + expect(isChainSupported(0)).toBe(false); + }); + }); + + describe('getCaipNetworkId', () => { + it('returns CAIP-2 network ID for Ethereum', () => { + expect(getCaipNetworkId(1)).toBe('eip155:1'); + }); + + it('returns CAIP-2 network ID for Arbitrum', () => { + expect(getCaipNetworkId(42161)).toBe('eip155:42161'); + }); + + it('returns undefined for unsupported chain', () => { + expect(getCaipNetworkId(12345)).toBeUndefined(); + }); + }); +}); diff --git a/src/services/walletconnect/connectionHealth.ts b/src/services/walletconnect/connectionHealth.ts new file mode 100644 index 0000000..6570660 --- /dev/null +++ b/src/services/walletconnect/connectionHealth.ts @@ -0,0 +1,50 @@ +import type { WalletConnectSessionState } from './types'; + +export type ConnectionHealthStatus = 'healthy' | 'degraded' | 'disconnected' | 'unknown'; + +export interface ConnectionHealth { + status: ConnectionHealthStatus; + connectedDurationMs: number | null; + staleSinceMs: number | null; + issues: string[]; +} + +const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes + +export function assessConnectionHealth( + session: WalletConnectSessionState, + nowMs: number = Date.now() +): ConnectionHealth { + const issues: string[] = []; + + if (session.status !== 'connected') { + return { + status: 'disconnected', + connectedDurationMs: null, + staleSinceMs: null, + issues: [`session_status:${session.status}`], + }; + } + + const connectedAt = session.connectedAt ? new Date(session.connectedAt).getTime() : null; + const lastUpdatedAt = new Date(session.lastUpdatedAt).getTime(); + + const connectedDurationMs = connectedAt !== null ? nowMs - connectedAt : null; + const staleSinceMs = nowMs - lastUpdatedAt; + + if (!session.address) issues.push('missing_address'); + if (!session.chainId) issues.push('missing_chain_id'); + if (!session.sessionTopic) issues.push('missing_session_topic'); + if (staleSinceMs > STALE_THRESHOLD_MS) issues.push('session_stale'); + + const status: ConnectionHealthStatus = + issues.length === 0 ? 'healthy' : issues.includes('session_stale') ? 'degraded' : 'degraded'; + + return { status, connectedDurationMs, staleSinceMs, issues }; +} + +export function formatConnectionDuration(ms: number): string { + if (ms < 60_000) return `${Math.floor(ms / 1000)}s`; + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`; + return `${Math.floor(ms / 3_600_000)}h`; +} diff --git a/src/services/walletconnect/multiChain.ts b/src/services/walletconnect/multiChain.ts new file mode 100644 index 0000000..586c99b --- /dev/null +++ b/src/services/walletconnect/multiChain.ts @@ -0,0 +1,52 @@ +import { WALLETCONNECT_CHAINS, getWalletConnectChain } from './chains'; +import type { WalletConnectChainDefinition } from './types'; + +export interface ChainSwitchResult { + success: boolean; + chainId: number; + chain: WalletConnectChainDefinition | undefined; + error?: string; +} + +export interface MultiChainState { + activeChainId: number; + supportedChainIds: number[]; + chains: WalletConnectChainDefinition[]; +} + +export function buildMultiChainState(activeChainId: number): MultiChainState { + return { + activeChainId, + supportedChainIds: WALLETCONNECT_CHAINS.map((c) => c.chainId), + chains: WALLETCONNECT_CHAINS.map((c) => ({ ...c })), + }; +} + +export function switchChain(state: MultiChainState, targetChainId: number): ChainSwitchResult { + if (!state.supportedChainIds.includes(targetChainId)) { + return { + success: false, + chainId: state.activeChainId, + chain: getWalletConnectChain(state.activeChainId), + error: `chain_not_supported:${targetChainId}`, + }; + } + + return { + success: true, + chainId: targetChainId, + chain: getWalletConnectChain(targetChainId), + }; +} + +export function getActiveChain(state: MultiChainState): WalletConnectChainDefinition | undefined { + return getWalletConnectChain(state.activeChainId); +} + +export function isChainSupported(chainId: number): boolean { + return WALLETCONNECT_CHAINS.some((c) => c.chainId === chainId); +} + +export function getCaipNetworkId(chainId: number): string | undefined { + return getWalletConnectChain(chainId)?.caipNetworkId; +}