import type { Network, NetworkId, Connector, ConnectorConnectOptions, ConnectorResult, SwitchNetworkResult, WalletConnectConfig, WalletConnectProvider, TransactionRequest, TransactionResult, NetworkRpcMap, SignatureResult, EIP712TypedData, WalletLifecycleEvent, WalletLifecycleContext, Namespace } from '@meshconnect/uwc-types' import { WalletConnectClient, toTelemetryObserver, type WalletConnectTelemetryObserver } from './services/walletconnect-client' import { ConnectionService } from './services/connection-service' import { NetworkSwitchService } from './services/network-switch-service' import { SignatureService } from './services/signature-service' import { TransactionService } from './services/transaction-service' // The shape checks below are plain char loops, not regexes: the payload is // wallet-supplied, and the SAST gate fails the pipeline for ANY regex over // untrusted input (WS-I002-JAVASCRIPT-00082), linear-time or not. /** Non-empty and every char is a decimal digit. */ function isDecimalString(value: string): boolean { if (value.length === 0) return false for (let i = 0; i < value.length; i++) { const c = value.charCodeAt(i) if (c < 48 || c > 57) return false } return true } /** `0x` followed by at least one hex digit, nothing else. */ function isHexQuantity(value: string): boolean { if (value.length < 3 || !value.startsWith('0x')) return false for (let i = 2; i < value.length; i++) { const c = value.charCodeAt(i) const hex = (c >= 48 && c <= 57) || (c >= 97 && c <= 102) || (c >= 65 && c <= 70) if (!hex) return false } return true } /** * CAIP-2 shape: `[a-z0-9]{3,8}` namespace, one colon, `[-_a-zA-Z0-9]{1,32}` * reference. The reference charset excludes `:`, so a second colon fails. */ function isCaip2ChainId(value: string): boolean { const colon = value.indexOf(':') if (colon < 3 || colon > 8) return false const referenceLength = value.length - colon - 1 if (referenceLength < 1 || referenceLength > 32) return false for (let i = 0; i < colon; i++) { const c = value.charCodeAt(i) if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 122))) return false } for (let i = colon + 1; i < value.length; i++) { const c = value.charCodeAt(i) const ok = (c >= 48 && c <= 57) || (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || c === 45 || // '-' c === 95 // '_' if (!ok) return false } return true } /** * Normalize a WC `session_event` chainChanged payload to CAIP-2. The NEW chain * travels in `event.data` (hex / decimal string / number per the EIP-1193 * convention — WalletConnect's own EthereumProvider reads `event.data`); the * sibling `params.chainId` is only the CAIP-2 scope the event was emitted * under, i.e. potentially the PRE-switch chain, so it donates its namespace * and nothing else. Unparseable data yields undefined — a content-free * `chainChanged` beats a wrong chain id. */ function toCaipChainId(data: unknown, scope: unknown): NetworkId | undefined { if (typeof data === 'string' && isCaip2ChainId(data)) { // Some wallets already send CAIP-2. An eip155 reference must be DECIMAL // per CAIP-2, so a non-decimal ref (`eip155:0x1`) re-enters the numeric // normalization below — the namespace survives via the `scope` argument. // The shape check guarantees one colon with a non-empty tail; the // fallback only satisfies noUncheckedIndexedAccess. const [namespace, reference = ''] = data.split(':') if (namespace !== 'eip155' || isDecimalString(reference)) { return data as NetworkId } return toCaipChainId(reference, data) } let decimal: number | undefined if (typeof data === 'string') { // Full-shape match before converting: parseInt('0x1junk', 16) === 1. if (isHexQuantity(data)) { decimal = parseInt(data, 16) } else if (isDecimalString(data)) { decimal = parseInt(data, 10) } } else if (typeof data === 'number' && Number.isInteger(data) && data >= 0) { decimal = data } // isSafeInteger: beyond 2^53-1 parseInt has already lost precision — // emitting a silently-wrong chain id is worse than emitting none. if (decimal === undefined || !Number.isSafeInteger(decimal)) return undefined const namespace = typeof scope === 'string' && scope.includes(':') ? scope.split(':')[0] : 'eip155' return `${namespace}:${decimal}` as NetworkId } export class WalletConnectConnector implements Connector { private walletConnectClient: WalletConnectClient private connectionService: ConnectionService private networkSwitchService: NetworkSwitchService private signatureService: SignatureService private transactionService: TransactionService private networkRpcMap: NetworkRpcMap constructor( config: WalletConnectConfig, networkRpcMap?: NetworkRpcMap, 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, networkRpcMap?: NetworkRpcMap, onRelayError?: (error: unknown) => void, onInitError?: (error: unknown) => void ) constructor( config: WalletConnectConfig, networkRpcMap: NetworkRpcMap = {}, observerOrOnRelayError?: | WalletConnectTelemetryObserver | ((error: unknown) => void), onInitError?: (error: unknown) => void ) { this.walletConnectClient = new WalletConnectClient( config, toTelemetryObserver(observerOrOnRelayError, onInitError) ) this.networkRpcMap = networkRpcMap this.connectionService = new ConnectionService(this.walletConnectClient) this.networkSwitchService = new NetworkSwitchService( this.walletConnectClient, this.connectionService ) this.signatureService = new SignatureService( this.walletConnectClient, this.connectionService ) this.transactionService = new TransactionService( this.walletConnectClient, this.connectionService, this.networkRpcMap ) } async connect( network: Network, provider?: WalletConnectProvider, options?: ConnectorConnectOptions ): Promise { // This connector always hands off to the wallet, so it cannot keep the // `silentOnly` never-prompt promise. Refuse it as a caller error, not a // `WalletNotAuthorizedError`: that one reads as an expected miss. if (options?.silentOnly) { throw new Error( 'silentOnly is not supported by the WalletConnect connector' ) } if (!provider) { throw new Error( 'WalletConnectProvider is required for WalletConnect connection' ) } return this.connectionService.connect(network, provider) } /** * Generate a pairing URI ahead of wallet selection (warms the relay; a later * `connect()` adopts the same pairing). Pass the UNION of the session's * WalletConnect wallets' networks so one pairing works for any wallet later * selected. See `ConnectionService.beginPairing`. */ async beginPairing( provider: Pick ): Promise { return this.connectionService.beginPairing(provider) } getConnectionURI(): string { const uri = this.connectionService.getConnectionURI() if (!uri) { throw new Error('No connection URI available. Call connect() first.') } return uri } async disconnect(): Promise { return this.connectionService.disconnect() } clearSession(): void { this.connectionService.clearSession() } async switchNetwork(network: Network): Promise { return this.networkSwitchService.switchNetwork(network) } async signMessage(message: string): Promise { return this.signatureService.signMessage(message) } async signTypedData(typedData: EIP712TypedData): Promise { return this.signatureService.signTypedData(typedData) } async sendTransaction( request: TransactionRequest ): Promise { return this.transactionService.sendTransaction(request) } /** * Sign a Solana transaction over WalletConnect without broadcasting. * Mirrors InjectedConnector.signSolanaTransactionBytes so the core router can branch on * connection mode without the call sites caring which connector backs the active wallet. * * Used by fee-payer relay flows (Mesh BE owns broadcast); never broadcasts via the wallet. */ async signSolanaTransactionBytes( serializedTx: Uint8Array ): Promise { return this.transactionService.signSolanaTransactionBytes(serializedTx) } /** * Observe post-connect lifecycle from the WalletConnect SignClient. Unlike the * injected path, the SignClient lives IN this context (its own relay, not the * iframe bridge), so this works in the production iframe too — no bridge needed. * * Maps the verified SignClient events (`@walletconnect/types`): `session_delete` * → disconnect, `session_expire` → sessionExpired, and `session_event` (CAIP * `accountsChanged` / `chainChanged`, the latter normalized to CAIP-2 from * `event.data` — see `toCaipChainId`). The account address in the event * payload is never forwarded. Returns undefined before a session exists so * the core reports lifecycle unavailable. * * TOPIC FILTER: SignClient events are client-global and WC v2 persists old * sessions in storage, so a stale session expiring would otherwise surface as a * phantom disconnect/expiry of the live one. Every payload carries a `topic` * (`@walletconnect/types` BaseEventArgs); only the ACTIVE session's topic is * forwarded, resolved per event so a restore/re-connect is honoured live. */ onLifecycleEvent( listener: (event: WalletLifecycleEvent) => void, // The active-session TOPIC (below) is the discriminator here — the context // names the session but carries no topic, so it is accepted for contract // parity and unused. _context: WalletLifecycleContext ): (() => void) | undefined { const client = this.walletConnectClient.getExistingClient() if (!client) return undefined const isActiveTopic = (topic: string | undefined): boolean => { const active = this.connectionService.getSession()?.topic return active !== undefined && topic === active } const onDelete = (args: { topic: string }) => { if (isActiveTopic(args?.topic)) listener({ type: 'disconnect' }) } const onExpire = (args: { topic: string }) => { if (isActiveTopic(args?.topic)) listener({ type: 'sessionExpired' }) } const onSessionEvent = (args: { topic: string params: { event: { name: string; data?: unknown }; chainId: string } }) => { if (!isActiveTopic(args?.topic)) return const name = args?.params?.event?.name if (name === 'accountsChanged') { // Address dropped — signal THAT it changed, not which. listener({ type: 'accountChanged' }) } else if (name === 'chainChanged') { // The new chain is in `event.data`; `params.chainId` is only the scope. const chainId = toCaipChainId( args?.params?.event?.data, args?.params?.chainId ) listener( chainId ? { type: 'chainChanged', chainId } : { type: 'chainChanged' } ) } } client.on('session_delete', onDelete) client.on('session_expire', onExpire) client.on('session_event', onSessionEvent) return () => { client.off('session_delete', onDelete) client.off('session_expire', onExpire) client.off('session_event', onSessionEvent) } } /** * Real account-sync counterpart to `onLifecycleEvent` (session_event * `accountsChanged`). Unlike the telemetry stream, this re-reads the * CURRENT session's account list for the affected namespace via * `ConnectionService.getAccountsForNamespace` rather than trying to parse * the event's own (wallet-defined, non-standardized) `event.data` shape — * see the design spec's "open risk" note if this needs revisiting against * a specific wallet found not to update `session.namespaces` before firing * the event. */ onAccountsChanged( listener: (accounts: string[]) => void, context: WalletLifecycleContext ): (() => void) | undefined { const client = this.walletConnectClient.getExistingClient() if (!client) return undefined const isActiveTopic = (topic: string | undefined): boolean => { const active = this.connectionService.getSession()?.topic return active !== undefined && topic === active } const onSessionEvent = (args: { topic: string params: { event: { name: string; data?: unknown }; chainId: string } }) => { if (!isActiveTopic(args?.topic)) return if (args?.params?.event?.name !== 'accountsChanged') return const namespace = args.params.chainId.split(':')[0] as Namespace // A single WC topic can carry MULTIPLE approved namespaces (e.g. // eip155 + solana in one session). `context.namespace` names the ONE // this subscription was attached to observe — an accountsChanged for // a DIFFERENT namespace on the same topic must not reach `listener`, // or the caller (bound to e.g. eip155) would receive the other // namespace's addresses and write them into its own active address. if (context.namespace !== undefined && namespace !== context.namespace) return listener(this.connectionService.getAccountsForNamespace(namespace)) } client.on('session_event', onSessionEvent) return () => { client.off('session_event', onSessionEvent) } } }