import type { SessionTypes } from '@walletconnect/types' import type { Network, AvailableAddress, ConnectorResult, WalletConnectProvider, Namespace } from '@meshconnect/uwc-types' import type { WalletConnectClient } from './walletconnect-client' import { parseAccount, getNamespaceFromNetworkId } from '../utils/account-utils' import { parseError } from '../utils/error-utils' import { SOLANA_WC_METHODS } from './solana/solana-methods' import { TRON_WC_METHODS } from './tron/tron-methods' /** * A pairing generated ahead of the user's wallet selection (see * `ConnectionService.beginPairing`). Holds the URI plus the (uncalled) approval * function so `connect()` can adopt it instead of creating a second pairing. * `approval` is stored uncalled so an unconsumed pairing that expires never * produces an unhandled rejection. */ type PendingPairing = { uri: string approval: () => Promise expiresAt: number } /** * Service for handling WalletConnect connections */ export class ConnectionService { private currentSession: SessionTypes.Struct | null = null private connectionUri: string | null = null private activeNetwork: Network | null = null private activeAddress: string | null = null private pendingPairing: PendingPairing | null = null /** * In-flight mint promise. Concurrent `beginPairing` calls (parallel React * effects / double renders) share this single mint instead of each racing to * open its own relay subscription + proposal before `pendingPairing` is set. */ private pendingPairingMint: Promise | null = null /** * Bumped by `disconnect()` to invalidate any in-flight mint. A `beginPairing` * warm-up that was still awaiting when the connection was reset must not * repopulate `pendingPairing` after the fact (and be adopted by a later * `connect()`); `mintPairing` checks this before publishing. */ private pairingGeneration = 0 /** * True for the full duration of `connect()`. A warm-up mint that resolves while * a connect is in progress (including before `connect()` has set `connectionUri` * in `finalizePairing`) must not publish a pending pairing, or it would leave an * orphan for a later `connect()` to adopt. */ private isConnecting = false /** * Conservative TTL for a pre-generated pairing. WalletConnect proposals expire * at ~5 min; stay under that so an adopted pairing is never already-dead. */ private static readonly PENDING_PAIRING_TTL_MS = 4 * 60 * 1000 constructor(private walletConnectClient: WalletConnectClient) {} /** * Generate a WalletConnect pairing (URI) WITHOUT waiting for wallet approval. * Creating the pairing topic opens the relay socket (warming a cold client), * and the returned URI can be deep-linked before `connect()` is called. The * approval function is retained (uncalled) for `connect()` to adopt. * * Idempotent within the TTL: if a still-valid pending pairing already exists, * its URI is returned as-is rather than minting a second one. Every mint opens * a relay subscription and publishes a proposal that would otherwise leak until * the SDK's ~5-min expiry, so a consumer that warms on every picker view (or a * re-render) does not accumulate abandoned pairings. An expired pending pairing * falls through to a fresh mint. * * Does NOT set `connectionUri`: the URI is returned to the caller directly, and * `connectionUri` is reserved for an in-flight `connect()` (set by * `finalizePairing`) so a late-resolving `beginPairing` can never overwrite the * URI a concurrent `connect()` is polling on. * * Concurrent calls are deduped: parallel invocations share one in-flight mint * rather than each opening its own relay subscription + proposal. Errors are * normalized through `parseError`, so a direct `WalletConnectConnector` * consumer sees the same `WalletConnectorError` surface as `connect()`. * * `provider.supportedNetworkIds` should be the UNION of the session's * WalletConnect wallets' networks, so the single pairing's `optionalNamespaces` * work for whichever wallet the user later selects (optionalNamespaces let a * wallet approve only the subset it supports). `provider` is only consulted when * a fresh pairing is minted; a call that reuses a still-valid pending pairing * returns its existing URI and ignores the argument (the sole caller, * `UniversalWalletConnector.beginPairing`, always passes the same union). */ async beginPairing( provider: Pick ): Promise { // A warm-up can only produce an adoptable pairing while idle. If a connect is // in progress or a session is already active (e.g. restored via setSession), // refuse — consistent with mintPairing's superseded check — and drop any stale // cached pairing rather than reusing or minting a URI the connector should no // longer treat as an adoptable warm-up. Best-effort callers get undefined. if (this.isConnecting || this.currentSession) { this.pendingPairing = null throw parseError( new Error( 'WalletConnect warm-up skipped: a connect is already in progress or a session is active' ) ) } // Idempotent: reuse a still-valid pending pairing instead of minting a // second one (which would leak a relay subscription + proposal until the // SDK's ~5-min expiry). An expired pairing falls through to a fresh mint. const existing = this.pendingPairing if (existing && Date.now() < existing.expiresAt) { return existing.uri } // Dedupe concurrent calls: a parallel call made while the first is still // awaiting getClient()/connect() reuses that in-flight mint rather than // racing to open a second relay subscription + proposal. const mint = (this.pendingPairingMint ??= this.mintPairing(provider)) try { return await mint } finally { // Only the owner clears the slot, so a fresh mint started by a later call // (e.g. after this one rejected) is not wiped out. if (this.pendingPairingMint === mint) { this.pendingPairingMint = null } } } /** * Mint a fresh pairing: open the relay socket + generate the URI, and store it * as the pending pairing. If a disconnect/connect/session supersedes the warm-up * while it is awaiting, it throws WITHOUT publishing rather than returning a URI * that no later `connect()` can adopt. Errors are wrapped with `parseError` for a * consistent `WalletConnectorError` surface (matching `connect()`). */ private async mintPairing( provider: Pick ): Promise { const generation = this.pairingGeneration try { const client = await this.walletConnectClient.getClient() const optionalNamespaces = this.buildNamespacesFromProvider(provider) const connectResult = await client.connect({ optionalNamespaces }) if (!connectResult.uri) { throw new Error('No connection URI generated') } // Publish the pairing only when this warm-up is still current AND nothing // has superseded it: a disconnect() intervened (generation bumped), a session // became active, or a connect() started. If superseded, the pairing can no // longer be adopted by a later connect(), so we must NOT hand its URI back — // a caller that deep-linked it would open the wallet to a proposal this // connector never awaits. Fail the warm-up instead; the best-effort caller // resolves undefined and falls back to a normal connect(). if ( generation !== this.pairingGeneration || this.currentSession || this.isConnecting ) { throw new Error( 'WalletConnect warm-up superseded before it could be published' ) } this.pendingPairing = { uri: connectResult.uri, approval: connectResult.approval, expiresAt: Date.now() + ConnectionService.PENDING_PAIRING_TTL_MS } return connectResult.uri } catch (error) { throw parseError(error) } } /** Take the pending pairing iff still valid (not expired); clears it either way. */ private takeValidPendingPairing(): PendingPairing | null { const pending = this.pendingPairing this.pendingPairing = null if (!pending || Date.now() >= pending.expiresAt) { return null } return pending } /** * Second half of a connection: await the wallet's approval on an existing * pairing, then derive the session/addresses for the selected network + wallet. * Shared by the fresh-connect path and the adopt-pending-pairing path. Sets * `connectionUri` to the pairing URI while awaiting (so `getConnectionURI` can * surface it for the QR/deeplink) and clears it once resolved or failed. */ private async finalizePairing( network: Network, provider: WalletConnectProvider, uri: string, approval: () => Promise ): Promise { this.connectionUri = uri try { const session = await approval() this.connectionUri = null // Derive addresses BEFORE committing session state: extractAddresses can // throw (e.g. no account for the requested network), and a failed connect // must not leave currentSession set — that would be a half-connected state // and would wrongly make beginPairing() fail-fast on its currentSession guard. const { address, availableAddresses } = this.extractAddresses( session, network, provider ) this.currentSession = session this.activeNetwork = network this.activeAddress = address return { networkId: network.id, address, availableAddresses } } catch (approvalError) { this.connectionUri = null throw approvalError } } /** * Connect to a wallet via WalletConnect * This will store the URI and wait for approval */ async connect( network: Network, provider: WalletConnectProvider ): Promise { this.isConnecting = true try { // Adopt a still-valid pre-generated pairing when present: reuse its URI + // approval so we await the SAME pairing the caller may have already // deep-linked to, rather than minting a second one. Failures (a user // rejection or a genuine error) PROPAGATE — we deliberately do NOT swallow // them and silently mint a fresh pairing, which would re-prompt the user for // something they just declined. `takeValidPendingPairing` already excluded an // expired pairing, so there is no stale-pairing case left to recover from here. const pending = this.takeValidPendingPairing() if (pending) { return await this.finalizePairing( network, provider, pending.uri, pending.approval ) } const client = await this.walletConnectClient.getClient() // Build namespaces from the provider's supported network IDs const optionalNamespaces = this.buildNamespacesFromProvider(provider) // Initiate connection - this starts the connection process const connectResult = await client.connect({ optionalNamespaces }) if (!connectResult.uri) { throw new Error('No connection URI generated') } // The main thread polls getConnectionURI while finalizePairing awaits // approval; finalizePairing sets/clears this.connectionUri. return await this.finalizePairing( network, provider, connectResult.uri, connectResult.approval ) } catch (error) { this.connectionUri = null // Clear URI on error throw parseError(error) } finally { this.isConnecting = false } } /** * Get the current connection URI */ getConnectionURI(): string | null { return this.connectionUri } /** * Build WalletConnect namespaces from provider's supported network IDs */ private buildNamespacesFromProvider( provider: Pick ): Record< string, { chains: string[] methods: string[] events: string[] } > { const namespaces: Record< string, { chains: string[] methods: string[] events: string[] } > = {} // Group networks by namespace const namespaceGroups: Record = {} as Record< Namespace, string[] > provider.supportedNetworkIds.forEach(networkId => { const namespace = getNamespaceFromNetworkId(networkId) if (!namespaceGroups[namespace]) { namespaceGroups[namespace] = [] } namespaceGroups[namespace]!.push(networkId) }) // Build namespaces for each group Object.entries(namespaceGroups).forEach(([namespace, chains]) => { switch (namespace) { case 'eip155': namespaces['eip155'] = { chains, methods: [ 'personal_sign', 'eth_sign', 'eth_signTypedData', 'eth_signTypedData_v4', 'eth_sendTransaction', 'eth_sendRawTransaction', 'eth_accounts', 'eth_requestAccounts', 'wallet_switchEthereumChain', 'wallet_addEthereumChain' ], events: ['chainChanged', 'accountsChanged'] } break case 'solana': namespaces['solana'] = { chains, methods: Object.values(SOLANA_WC_METHODS), events: [] } break case 'tron': namespaces['tron'] = { chains, methods: Object.values(TRON_WC_METHODS), events: [] } break default: // For unknown namespaces, include basic methods namespaces[namespace] = { chains, methods: [], events: [] } } }) return namespaces } /** * Extract addresses from a WalletConnect session */ private extractAddresses( session: SessionTypes.Struct, network: Network, provider: WalletConnectProvider ): { address: string availableAddresses: AvailableAddress[] } { const availableAddresses: AvailableAddress[] = [] let primaryAddress = '' // Extract addresses from all namespaces Object.entries(session.namespaces).forEach(([, data]) => { data.accounts.forEach((account: string) => { try { const { networkId, address } = parseAccount(account) // Only include addresses for networks that are supported by the provider if (provider.supportedNetworkIds.includes(networkId)) { availableAddresses.push({ address, networkId }) // Set the primary address based on the requested network if (networkId === network.id && !primaryAddress) { primaryAddress = address } } } catch { // Skip invalid account formats } }) }) // If no address for the specific network, use the first available if (!primaryAddress && availableAddresses.length > 0) { primaryAddress = availableAddresses[0]?.address || '' } if (!primaryAddress) { throw new Error('No addresses found in session') } return { address: primaryAddress, availableAddresses } } /** * Re-read the live account list for a namespace from the current SignClient * session — used to sync a wallet-side account switch (`accountsChanged`) * without repeating `extractAddresses`'s primary-address/requested-network * selection, which doesn't apply to a live update (there is no "requested * network" for an in-session switch). */ getAccountsForNamespace(namespace: Namespace): string[] { if (!this.currentSession) return [] const accounts = this.currentSession.namespaces[namespace]?.accounts ?? [] const addresses: string[] = [] for (const account of accounts) { try { addresses.push(parseAccount(account).address) } catch { // Skip invalid account formats } } return addresses } /** * Get current session */ getSession(): SessionTypes.Struct | null { return this.currentSession } /** * Set session (for restoration) */ setSession(session: SessionTypes.Struct): void { this.currentSession = session } /** * Get active network */ getActiveNetwork(): Network | null { return this.activeNetwork } /** * Set active network (for network switching) */ setActiveNetwork(network: Network): void { this.activeNetwork = network } /** * Get active address */ getActiveAddress(): string | null { return this.activeAddress } /** * Set active address (for network/address switching) */ setActiveAddress(address: string): void { this.activeAddress = address } /** * Disconnect current session */ async disconnect(): Promise { if (this.currentSession) { await this.walletConnectClient.disconnect(this.currentSession.topic) } this.clearSession() } /** Forget local state when SignClient has already removed the session. */ clearSession(): void { this.currentSession = null this.connectionUri = null this.activeNetwork = null this.activeAddress = null // Drop any pre-generated pairing, and invalidate an in-flight mint (bump the // generation + clear the dedupe slot) so a warm-up from before this reset can // neither be adopted nor resurrected once it resolves. this.pendingPairing = null this.pendingPairingMint = null this.pairingGeneration++ } }