import SignClient from '@walletconnect/sign-client' import { RELAYER_EVENTS } from '@walletconnect/core' import { createBoundedDedup, safeErrorMessage } from '@meshconnect/uwc-types' import type { WalletConnectConfig } from '@meshconnect/uwc-types' /** * Cap on distinct relay-error messages remembered for dedup. Dedup is a storm * guard, not a permanent ledger — bounding it keeps memory flat on a long-lived * session even if messages are high-cardinality (timestamps / urls). */ export const MAX_RELAY_ERROR_DEDUP_KEYS = 100 export const MAX_REPORTED_RELAY_ERRORS = 100 const JWT_VALIDATION_RELAY_ERROR = /^WebSocket connection closed abnormally with code: 3000 \(JWT validation error: .+\)$/ const JWT_VALIDATION_RELAY_ERROR_KEY = 'walletconnect:jwt-validation:code-3000' function relayErrorDedupKey(message: string): string { return JWT_VALIDATION_RELAY_ERROR.test(message) ? JWT_VALIDATION_RELAY_ERROR_KEY : message } /** * Cap on relay transport-lifecycle emissions PER EVENT TYPE (disconnect and * reconnect budgeted independently) per client. A flapping socket IS the signal * we want, but bounding emissions keeps a pathological relay from flooding * telemetry on a long-lived session. Independent budgets so a drop-heavy flap * that exhausts the disconnect cap can never silently swallow the paired * reconnect — otherwise telemetry would show drops with no recovery for a socket * that did recover. */ export const MAX_REPORTED_RELAY_LIFECYCLE = 50 /** * Optional telemetry sinks for the WalletConnect client. Covers both the relay * transport (error / disconnect / reconnect) AND `SignClient.init` failures. * Every field is opt-in — an empty observer leaves behaviour unchanged. All * sinks are telemetry-only: they never alter connection behaviour, and a * throwing sink is swallowed so it can't break the SDK's relayer dispatch (or, * for `onInitError`, mask the always-rethrown init rejection). */ export interface WalletConnectTelemetryObserver { /** * Relay-socket failure: the relayer re-emits an abnormal WebSocket close under * `RELAYER_EVENTS.error` (e.g. code 3000 JWT validation error on clock skew), * detached from the connect() promise. Deduped by a stable error-family key. */ onRelayError?: (error: unknown) => void /** Relay-error telemetry reached its per-client emission budget. */ onRelayErrorCapped?: () => void /** * `SignClient.init` failure (storage/crypto, NOT relay). Telemetry-only — the * init rejection is always rethrown so `getClient()` callers see it unchanged. */ onInitError?: (error: unknown) => void /** * Relay transport socket dropped (relayer `disconnect`), emitted once per * outage. The lifecycle FACT — distinct from `onRelayError` (a close WITH an * error). This is the CARE-263 signal a suspended WebView produces silently. */ onRelayDisconnect?: () => void /** * Relay socket recovered (relayer `connect` after a drop). Pair with * `onRelayDisconnect` to measure the dead window. */ onRelayReconnect?: () => void } /** * Normalize the telemetry constructor slots into a `WalletConnectTelemetryObserver`. * * BACKWARD COMPATIBILITY: the published 0.8.x signature was positional — * `(…, onRelayError?, onInitError?)`. This PR moved to a single observer object; * the positional form is retained so external JS/TS consumers on 0.8.x don't * break at runtime. Disambiguate on the first telemetry slot: a FUNCTION is the * legacy `onRelayError` (the new form always passes an object). The legacy form * predates `onRelayDisconnect`/`onRelayReconnect`, so those are simply absent. */ export function toTelemetryObserver( observerOrOnRelayError?: | WalletConnectTelemetryObserver | ((error: unknown) => void), onInitError?: (error: unknown) => void ): WalletConnectTelemetryObserver { const onRelayError = typeof observerOrOnRelayError === 'function' ? observerOrOnRelayError : undefined // Legacy positional form when EITHER positional callback is present — this // includes the `(undefined, onInitError)` "init-only" call pattern, so that // sink is never silently dropped. Keys are added conditionally because // `exactOptionalPropertyTypes` forbids an explicit `undefined` on an optional. if (onRelayError || onInitError) { return { ...(onRelayError ? { onRelayError } : {}), ...(onInitError ? { onInitError } : {}) } } // New form: an observer object (or nothing). return typeof observerOrOnRelayError === 'object' && observerOrOnRelayError !== null ? observerOrOnRelayError : {} } /** * Service for managing WalletConnect client instance */ export class WalletConnectClient { private client: SignClient | null = null private initPromise: Promise | null = null /** Dedupe relay-error reports so a reconnect storm surfaces once, not per tick. */ private readonly relayErrorDedup = createBoundedDedup( MAX_RELAY_ERROR_DEDUP_KEYS ) /** Hard limit for relay-error telemetry when messages are high-cardinality. */ private relayErrorCount = 0 /** Ensures the telemetry-cap signal is emitted once per client. */ private relayErrorCapped = false /** Independent bounded counters so drop/recover telemetry can never starve each other. */ private relayDisconnectCount = 0 private relayReconnectCount = 0 /** True once the socket has dropped — gates `relayReconnected` to real recoveries. */ private relayHasDisconnected = false private readonly config: WalletConnectConfig private readonly telemetryObserver: WalletConnectTelemetryObserver /** * @param telemetryObserver - optional telemetry sinks (see * `WalletConnectTelemetryObserver`). Every field is opt-in; an empty/omitted * observer leaves behaviour unchanged. */ constructor( config: WalletConnectConfig, telemetryObserver?: WalletConnectTelemetryObserver ) /** * @deprecated Pass a {@link WalletConnectTelemetryObserver}. The positional * `(onRelayError, onInitError)` form is retained for backward compatibility * with the published 0.8.x signature and will be removed in a future major. */ constructor( config: WalletConnectConfig, onRelayError?: (error: unknown) => void, onInitError?: (error: unknown) => void ) constructor( config: WalletConnectConfig, observerOrOnRelayError?: | WalletConnectTelemetryObserver | ((error: unknown) => void), onInitError?: (error: unknown) => void ) { this.config = config this.telemetryObserver = toTelemetryObserver( observerOrOnRelayError, onInitError ) } /** * Initialize or get the WalletConnect client */ async getClient(): Promise { // If already initialized, return the client if (this.client) { return this.client } // If initialization is in progress, wait for it if (this.initPromise) { return this.initPromise } // Start initialization this.initPromise = this.initClient() this.client = await this.initPromise this.initPromise = null return this.client } /** * Initialize the WalletConnect client */ private async initClient(): Promise { if (!this.config.projectId) { throw new Error('WalletConnect projectId is required') } let url = this.config.metadata?.url || '' if (!url && typeof globalThis !== 'undefined') { // eslint-disable-next-line @typescript-eslint/no-explicit-any const global = globalThis as any if (global.location?.origin) { url = global.location.origin } } const metadata = { name: this.config.metadata?.name || 'Universal Wallet Connector', description: this.config.metadata?.description || 'Connect your wallet', url, icons: this.config.metadata?.icons || [] } let client: SignClient try { client = await SignClient.init({ projectId: this.config.projectId, metadata }) } catch (error) { // SignClient.init rejects on a storage (IndexedDB) or crypto init failure — NOT a // relay/network fault (the relay socket open is fire-and-forget in relayer.init, so // an unreachable relay surfaces via RELAYER_EVENTS.error, not here). getClient() // caches the rejected init promise (no retry today — see MFS-574), so WalletConnect // stays down for the whole page session. Surface it as a distinct telemetry signal // instead of a generic connect failure. The sink is guarded so a throwing observer // can't mask the real rejection, which is always rethrown. try { this.telemetryObserver.onInitError?.(error) } catch { // Telemetry must never replace the init rejection. } throw error } this.attachRelayErrorListener(client) this.attachRelayLifecycleListeners(client) return client } /** * Surface relay-socket failures as telemetry. The relay JsonRpcProvider re-emits * an abnormal WebSocket close under `RELAYER_EVENTS.error` — e.g. close code 3000 * "JWT Token is not yet valid" when the device clock is skewed past the relay's * 120s leeway. That error is otherwise detached from the connect() promise, so it * escapes the connect handoff and lands as an unhandled error. Forward it (deduped * per message) so it becomes a classified signal instead. This listener handles the * relayer ERROR stream only; the socket's clean up/down lifecycle is captured * separately by `attachRelayLifecycleListeners` (CARE-263 — a suspended WebView * drops the socket without emitting an `error`). Telemetry-only; never changes behaviour. */ private attachRelayErrorListener(client: SignClient): void { if ( !this.telemetryObserver.onRelayError && !this.telemetryObserver.onRelayErrorCapped ) { return } client.core?.relayer?.on(RELAYER_EVENTS.error, (error: unknown) => { if (this.relayErrorCount >= MAX_REPORTED_RELAY_ERRORS) { if (this.relayErrorCapped) return this.relayErrorCapped = true try { this.telemetryObserver.onRelayErrorCapped?.() } catch { // Telemetry must never break the relay. } return } // safeErrorMessage: this runs INSIDE the SDK's relayer dispatch, before // the sink guard below — a throwing toString() here would break the // emitter just like a throwing sink; the dedup key stays bounded too. const message = safeErrorMessage(error) const dedupKey = relayErrorDedupKey(message) if (!this.relayErrorDedup.firstSeen(dedupKey)) return if (this.relayErrorCount >= MAX_REPORTED_RELAY_ERRORS) return this.relayErrorCount++ try { this.telemetryObserver.onRelayError?.(error) } catch { // A throwing sink would propagate synchronously out of the WC SDK's // relayer emitter and prevent its own error handling (transportClose) // from running. Telemetry must never break the relay. } }) } /** * Surface the relay transport socket's up/down lifecycle as telemetry. This is * the signal `attachRelayErrorListener` misses: when iOS suspends the WebView * during a wallet deep-link the socket drops with no `error` event, WC core only * reconnects on its slow internal heartbeat, and any in-flight sign/connect * response strands in the meantime (CARE-263). Emitting `disconnect` + the * subsequent `connect` lets us see the drop and measure the dead window. * * Emitted at most once per outage: WC core re-emits `disconnect` on every failed * reconnect attempt, so a transition flag gates the emit to keep drop/recover 1:1 * (and bounds emissions); a counter cap backstops many distinct outages. Both * sinks are guarded so a throwing observer can never break the SDK's relayer * dispatch. Telemetry-only; it never changes connection behaviour. */ private attachRelayLifecycleListeners(client: SignClient): void { if ( !this.telemetryObserver.onRelayDisconnect && !this.telemetryObserver.onRelayReconnect ) return const relayer = client.core?.relayer if (!relayer) return // Guarded + capped emit with an INDEPENDENT budget per event type. Cap is only // consumed when a sink is wired; the guard keeps a throwing observer from // breaking the relayer's own dispatch. Separate budgets ensure a drop-heavy // flap can never exhaust the shared pool and silently drop the paired recovery. const report = ( sink: (() => void) | undefined, counter: 'relayDisconnectCount' | 'relayReconnectCount' ): void => { if (!sink) return if (this[counter] >= MAX_REPORTED_RELAY_LIFECYCLE) return this[counter]++ try { sink() } catch { // Telemetry must never break the relayer's lifecycle handling. } } relayer.on(RELAYER_EVENTS.disconnect, () => { // WC core re-emits `disconnect` on each failed reconnect during one outage — // emit only on the transition INTO disconnected so drop/recover stay 1:1 (and // relayDropCount counts outages, not re-emits). The relayer passes no payload // (socket errors arrive via RELAYER_EVENTS.error / onRelayError). if (this.relayHasDisconnected) return this.relayHasDisconnected = true report(this.telemetryObserver.onRelayDisconnect, 'relayDisconnectCount') }) relayer.on(RELAYER_EVENTS.connect, () => { // `connect` also fires on the FIRST socket open — only a connect that follows // a disconnect is a genuine recovery and pairs 1:1 with the dead window. if (!this.relayHasDisconnected) return this.relayHasDisconnected = false report(this.telemetryObserver.onRelayReconnect, 'relayReconnectCount') }) } /** * The initialized client if `connect()` already ran, else null. Synchronous — * lets callers attach lifecycle listeners post-connect without forcing a * re-init or awaiting. */ getExistingClient(): SignClient | null { return this.client } /** * Disconnect all sessions */ async disconnectAll(): Promise { const client = await this.getClient() const sessions = client.session.getAll() for (const session of sessions) { try { await client.disconnect({ topic: session.topic, reason: { code: 6000, message: 'User disconnected' } }) } catch { // Silently ignore disconnect errors } } } /** * Disconnect a specific session */ async disconnect(topic: string): Promise { const client = await this.getClient() await client.disconnect({ topic, reason: { code: 6000, message: 'User disconnected' } }) } }