Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/services/walletconnect/__tests__/connectionHealth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { assessConnectionHealth, formatConnectionDuration } from '../connectionHealth';
import type { WalletConnectSessionState } from '../types';

function makeSession(overrides: Partial<WalletConnectSessionState> = {}): 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');
});
});
87 changes: 87 additions & 0 deletions src/services/walletconnect/__tests__/multiChain.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
50 changes: 50 additions & 0 deletions src/services/walletconnect/connectionHealth.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
52 changes: 52 additions & 0 deletions src/services/walletconnect/multiChain.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading