import type { Network, Namespace, ExtensionInjectedProvider, IntegratedBrowserInjectedProvider, ConnectorResult, SwitchNetworkResult, AvailableAddress, SolanaMetadata } from '@meshconnect/uwc-types' import { EthereumWalletService } from './ethereum/ethereum-wallet-service' import { SolanaWalletService } from './solana/solana-wallet-service' import { TronWalletService } from './tron/tron-wallet-service' import { TonWalletService } from './ton/ton-wallet-service' import { parseError } from '../utils/error-utils' /** * True when running inside an iframe (`window.top !== window.self`). The * try/catch is a safety net for sandboxed/restricted frames where evaluating * that can throw — any such error is treated as "framed". */ function isFramedEmbed(): boolean { try { return typeof window !== 'undefined' && window.top !== window.self } catch { return true } } /** * Whether to skip the EAGER cross-namespace `connect()` for a SECONDARY namespace * (the ONC-3429 gather) while framed. * * EVM/TON (`namespaceSafeFramed` = false, the default): inside an iframe the * catalog's follow-up ownership-verification SIGNATURE for the secondary namespace * is frame-position-gated by wallets like Trust (`dapp.frames-disallowed`) — extra * prompts plus a hard failure on a namespace the user never asked to use — so the * eager connect is skipped when framed (ONC-3383). (These can be allowed once their * bridged sign path is verified end-to-end, same as Solana/Tron below.) * * Solana & Tron (`namespaceSafeFramed` = true): these route to the TOP frame when a * `BridgeParent` is present — Solana via wallet-standard (or the bridge), and here in * `ConnectionManager` Tron via `TronWalletService`, whose provider is resolved from * the bridge-populated `window.tronWallets` (see `tron-discovery`). (The parallel * `InjectedConnector.enrichWithTron` path uses the bridge-aware Tron connector, #176.) * So the connect and its verification signature execute top-frame and satisfy Trust's * frame-position gate. The eager gather is * therefore allowed framed. (An earlier attempt gated Solana on the bridge * `window.walletStandardWallets` list being populated, but that list is frequently * empty in real embeds — `getSolanaWallets` discovers the adapter via direct * wallet-standard registration, not the bridge list — so gating on it wrongly skipped * the gather and dropped e.g. Phantom's Solana; a blanket framed skip likewise * dropped a multi-namespace wallet's Tron address, e.g. Trust connected on EVM never * surfaced Tron.) If the bridge is absent the connect is caught at the call site and * the gather degrades gracefully. * * `checkExistingConnection` still runs at every call site; the TARGET namespace * connects via its own path in `connect*`. In the top frame (extension) the eager * gather is preserved unchanged. */ function skipFramedSecondaryConnect(namespaceSafeFramed = false): boolean { return isFramedEmbed() && !namespaceSafeFramed } /** * Exception to Solana's `namespaceSafeFramed` exemption above (ONC-3536 * follow-up): wallets that declare a legacy `solana.injectedId` (currently * Coinbase/Base Wallet's `coinbaseSolana`) treat the ACTIVE NAMESPACE as * exclusive inside their own in-app browser. Any secondary Solana connect * after an EVM connect — the eager gather OR the silent storage-flag * reconnect — parks the wallet's active namespace on Solana; the later * `wallet_switchEthereumChain` moves `window.ethereum`'s chain but not the * wallet's active namespace, so the EVM `eth_sendTransaction` never surfaces * an approval sheet (60s `walletTimedOut`). Proven with a Mesh-free PoC on * Coinbase Wallet 30.3.3: EVM connect → send = sheet shown; EVM connect → * `window.coinbaseSolana` connect → send = hang. * * The harm condition is RUNNING INSIDE THE WALLET'S OWN in-app browser — * signalled by `usingIntegratedBrowser`, which the consumer sets ONLY in the * wallet's dapp deep-link flow. That is the exact flow the hang PoC reproduced. * The gate keys on that flag regardless of frame position; the skip-report * payload records `framed` separately for triage. * * This gate does NOT key on `isFramedEmbed()` (ONC-231 regression fix). An * earlier version did, but every SDK integration runs Link in an iframe, so * `isFramedEmbed()` is true for ordinary desktop/embedded clients that are NOT * inside the wallet's in-app browser — where the gather is needed and harmless. * It silently dropped Base Wallet's Solana address (and SOL/SPL deposits) for * all framed clients. `isFramedEmbed()` also cannot distinguish "framed inside * the in-app browser" from "framed in desktop Chrome" — the consumer supplies * no in-app signal when framed — so it was undetectable-by-design over-fire. * * When the gate is on, a legacy-`injectedId` wallet gets NO secondary Solana * connect of any kind (the passive already-connected check still runs). * Wallet-Standard-only wallets (no `injectedId`, e.g. Phantom) keep the * gather, PRIMARY Solana connects (`connectSolana`) are untouched — the * ONC-3536 legacy fallback keeps working — and the desktop extension * (not an integrated browser) is unchanged. */ function skipLegacyInjectedSolanaSecondaryConnect( solanaMeta: SolanaMetadata | undefined, usingIntegratedBrowser: boolean ): boolean { return usingIntegratedBrowser && Boolean(solanaMeta?.injectedId) } /** * Payload for the `onSecondarySolanaConnectSkipped` triage sink: the ONC-3536 * gate was active for a legacy-`injectedId` wallet's secondary Solana gather — * in a framed embed OR top-level in the wallet's in-app browser (`framed` * distinguishes the two). Metadata key + discovery/runtime facets only — * never an address. */ export interface SecondarySolanaConnectSkippedInfo { /** The `solana.injectedId` metadata key that triggered the skip (e.g. `coinbaseSolana`). */ injectedId: string /** * What the active gate observed: `connectSuppressed` = a would-be connect * was skipped; `alreadyConnected` = the passive check found the provider * already authorized (no connect was needed or made) — if a hang still * follows, the parking predates the gather and is wallet-side state. */ outcome: 'connectSuppressed' | 'alreadyConnected' /** * True when the wallet was discovered via the legacy injected fallback * (true "Legacy mode"); false when it registered via the Wallet Standard. */ legacyInjected: boolean /** * True when the skip happened in a framed embed; false when it happened * top-level in the wallet's in-app browser (the dapp deep-link flow). */ framed: boolean } /** * Payload for the `onSecondarySolanaConnectGathered` sink: a legacy-`injectedId` * wallet's SECONDARY Solana address WAS gathered after an EVM connect (the * positive counterpart of {@link SecondarySolanaConnectSkippedInfo}). This is * the "the ONC-231 fix works" signal — in prod, filter `framed:true` to confirm * framed clients recover the Base Wallet Solana address that the over-broad gate * used to drop. Metadata key + discovery/runtime facets only — never an address. */ export interface SecondarySolanaConnectGatheredInfo { /** The `solana.injectedId` metadata key of the gathered wallet (e.g. `coinbaseSolana`). */ injectedId: string /** `existing` = surfaced via the passive/silent check; `connect` = a fresh connect was made. */ source: 'existing' | 'connect' /** True = wallet discovered via the legacy injected fallback (true "Legacy mode"); false = Wallet Standard. */ legacyInjected: boolean /** True = gathered while framed (the SDK-client case the ONC-231 fix restores); false = top frame. */ framed: boolean } /** * Cap for `injectedId` before it enters the dedup Set key or the triage * callback payload. The value originates in catalog metadata (not live wallet * input), but bound it here anyway — aligned with core telemetry's 64-char * egress cap — so an oversized value can't bloat the Set or reach consumers. */ const MAX_INJECTED_ID_LENGTH = 64 /** Optional collaborators for {@link ConnectionManager}. */ export interface ConnectionManagerOptions { /** * Whether this session runs inside a wallet's in-app browser (same flag the * `InjectedConnector` receives) — one of the two triggers of the ONC-3536 * legacy-Solana gather gate, see * {@link skipLegacyInjectedSolanaSecondaryConnect}. */ usingIntegratedBrowser?: boolean | undefined /** * Triage telemetry sink (ONC-3536 follow-up): invoked when the ONC-3536 * gate is active for a legacy-`injectedId` wallet. Fires once per * injectedId × outcome per instance — so it can fire twice for the same * injectedId (`connectSuppressed` and `alreadyConnected`). */ onSecondarySolanaConnectSkipped?: | ((info: SecondarySolanaConnectSkippedInfo) => void) | undefined /** * Positive telemetry sink (ONC-231): invoked when a legacy-`injectedId` * wallet's secondary Solana address IS gathered. Fires once per * injectedId × source per instance. Verifies the framed gather works. */ onSecondarySolanaConnectGathered?: | ((info: SecondarySolanaConnectGatheredInfo) => void) | undefined /** * Telemetry sink: the wallet-switch guard released live per-namespace state * that belonged to a different wallet (see * {@link ConnectionManager.checkExistingConnections}). Fires once per * connect attempt that released anything, with the released namespaces — * never an address. Observability only; the release happens regardless. */ onWalletConnectionsReleased?: | ((info: { namespaces: Namespace[] }) => void) | undefined } export class ConnectionManager { private currentNetworkId: string | null = null /** `${injectedId}:${outcome}` keys already reported via * `onSecondarySolanaConnectSkipped` — dedupes the emit across repeated * gathers per outcome (mirrors the legacy-discovery failure dedup in * `InjectedConnector`, which keys on injectedId alone). */ private readonly reportedSolanaGatherSkips = new Set() /** `${injectedId}:${source}` keys already reported via * `onSecondarySolanaConnectGathered` — dedupes the positive emit per source. */ private readonly reportedSolanaGathers = new Set() private readonly usingIntegratedBrowser: boolean private readonly onSecondarySolanaConnectSkipped?: | ((info: SecondarySolanaConnectSkippedInfo) => void) | undefined private readonly onSecondarySolanaConnectGathered?: | ((info: SecondarySolanaConnectGatheredInfo) => void) | undefined private readonly onWalletConnectionsReleased?: | ((info: { namespaces: Namespace[] }) => void) | undefined constructor( private ethereumService: EthereumWalletService, private solanaService: SolanaWalletService, private tronService: TronWalletService, private tonService: TonWalletService, options: ConnectionManagerOptions = {} ) { this.usingIntegratedBrowser = options.usingIntegratedBrowser ?? false this.onSecondarySolanaConnectSkipped = options.onSecondarySolanaConnectSkipped this.onSecondarySolanaConnectGathered = options.onSecondarySolanaConnectGathered this.onWalletConnectionsReleased = options.onWalletConnectionsReleased } /** Report a gathered legacy-injectedId Solana address, once per injectedId x source. */ private reportSecondarySolanaConnectGathered( meta: SolanaMetadata, source: SecondarySolanaConnectGatheredInfo['source'] ): void { const injectedId = meta.injectedId?.slice(0, MAX_INJECTED_ID_LENGTH) if (!injectedId) return const key = `${injectedId}:${source}` if (this.reportedSolanaGathers.has(key)) return this.reportedSolanaGathers.add(key) this.onSecondarySolanaConnectGathered?.({ injectedId, source, legacyInjected: meta.detectedWallet?.legacyInjected ?? false, framed: isFramedEmbed() }) } /** Report the ACTIVE gate's observation, once per injectedId x outcome. */ private reportSecondarySolanaConnectSkipped( meta: SolanaMetadata, outcome: SecondarySolanaConnectSkippedInfo['outcome'] ): void { const injectedId = meta.injectedId?.slice(0, MAX_INJECTED_ID_LENGTH) if (!injectedId) return const key = `${injectedId}:${outcome}` if (this.reportedSolanaGatherSkips.has(key)) return this.reportedSolanaGatherSkips.add(key) this.onSecondarySolanaConnectSkipped?.({ injectedId, outcome, legacyInjected: meta.detectedWallet?.legacyInjected ?? false, framed: isFramedEmbed() }) } /** * Release any live per-namespace connection that does NOT belong to the * wallet being connected (identified by `provider`'s detected metadata). * Connecting wallet B while wallet A is still connected must behave like a * fresh connect for B: without this, the in-memory reuse shortcuts below * (and the cross-namespace gathers) hand B's session wallet A's account — * the session shows A's address under B's name, signing keeps routing to * A's provider, and A's other-namespace addresses leak into B's * availableAddresses. Identity is compared per namespace on the live * surface (EVM/Tron provider, Solana adapter, TON jsBridgeKey), so a * same-wallet reconnect releases nothing and keeps its fast path. Local * state only — A's wallet-side permissions are untouched (the disconnect() * contract); TON is the exception, where the stale session is explicitly * ended over the JS Bridge so the old wallet doesn't keep a live session. * * Fail-closed by design: when the target wallet's identity for a namespace * can't be established (no `detectedWallet` uuid, or the uuid no longer * resolves), a live connection is released rather than reused — reusing * state we can't attribute to the requested wallet is exactly the * cross-wallet mixup this guard exists to prevent. This cannot strand a * same-wallet reconnect: every path that reaches the connected state * (`connect*` throws, the silent checks skip) requires the detected uuid, * and detection stamps that uuid on the same metadata object `connect()` * passes back in, so a wallet that was ever connected presents a uuid that * resolves to its own live surface. The one benign spurious release is * Solana adapter-instance churn (a consumer re-running discovery replaces * adapter instances): the follow-up silent check re-binds the fresh adapter * from `wallet.accounts` without a wallet round-trip. */ private async releaseOtherWalletConnections( provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider ): Promise { const released: Namespace[] = [] const connectedEthProvider = this.ethereumService.getConnectedProvider() if (connectedEthProvider) { const uuid = provider.namespaceMetaData?.eip155?.detectedWallet?.uuid const target = uuid ? this.ethereumService.findWalletByUuid(uuid) : undefined if (target?.provider !== connectedEthProvider) { this.ethereumService.disconnect() released.push('eip155') } } const connectedAdapter = this.solanaService.getConnectedAdapter() if (connectedAdapter) { const uuid = provider.namespaceMetaData?.solana?.detectedWallet?.uuid const target = uuid ? this.solanaService.findWalletByUuid(uuid) : undefined if (target?.adapter !== connectedAdapter) { this.solanaService.disconnect() released.push('solana') } } const connectedTronProvider = this.tronService.getConnectedProvider() if (connectedTronProvider) { const uuid = provider.namespaceMetaData?.tron?.detectedWallet?.uuid const target = uuid ? this.tronService.findWalletByUuid(uuid) : undefined if (target?.provider !== connectedTronProvider) { this.tronService.disconnect() released.push('tron') } } if (this.tonService.isConnected()) { const jsBridgeKey = provider.namespaceMetaData?.tvm?.detectedWallet?.jsBridgeKey if ( !jsBridgeKey || this.tonService.getConnectedJsBridgeKey() !== jsBridgeKey ) { await this.tonService.disconnect() released.push('tvm') } } // Surface the release (observability only — it already happened). An // unexpected emit, e.g. during a same-wallet reconnect, is the prod // signal that a namespace's identity comparison broke. if (released.length > 0) { this.onWalletConnectionsReleased?.({ namespaces: released }) } } /** * Check and add existing connections from other namespaces */ async checkExistingConnections( targetNamespace: 'eip155' | 'solana' | 'tron' | 'tvm', provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider ): Promise<{ address: string | null availableAddresses: AvailableAddress[] }> { // A different wallet's live state must not be reused as "already // connected" — drop it first so every branch below only ever sees state // belonging to the wallet actually being connected. await this.releaseOtherWalletConnections(provider) const availableAddresses: AvailableAddress[] = [] let currentAddress: string | null = null if (targetNamespace === 'eip155') { // Check Ethereum connection const ethAccount = this.ethereumService.getAccount() if (ethAccount) { currentAddress = ethAccount } else { // Try to get existing connection without prompting const walletUuid = provider.namespaceMetaData?.eip155?.detectedWallet?.uuid if (walletUuid) { const wallet = this.ethereumService.findWalletByUuid(walletUuid) if (wallet?.provider) { const accounts = await this.ethereumService.checkExistingConnection( wallet.provider ) if (accounts && accounts.length > 0) { currentAddress = accounts[0] || null this.ethereumService.setConnectionState( wallet.provider, currentAddress ) } } } } // Add Ethereum addresses if (currentAddress) { const ethAddresses = this.ethereumService.buildAvailableAddresses( provider.supportedNetworkIds, currentAddress ) availableAddresses.push(...ethAddresses) } await this.checkAndAddOtherNamespaceAddresses( 'eip155', provider, availableAddresses ) } else if (targetNamespace === 'solana') { // Check Solana connection const solanaAccount = this.solanaService.getAccount() if (solanaAccount) { currentAddress = solanaAccount } else { // Try to get existing connection without prompting const walletUuid = provider.namespaceMetaData?.solana?.detectedWallet?.uuid if (walletUuid) { const wallet = this.solanaService.findWalletByUuid(walletUuid) if (wallet?.adapter) { const address = await this.solanaService.checkExistingConnection( wallet.adapter, walletUuid ) if (address) { currentAddress = address this.solanaService.setConnectionState( wallet.adapter, address, walletUuid ) } } } } // Add Solana addresses if (currentAddress) { const solanaAddresses = this.solanaService.buildAvailableAddresses( provider.supportedNetworkIds, currentAddress ) availableAddresses.push(...solanaAddresses) } await this.checkAndAddOtherNamespaceAddresses( 'solana', provider, availableAddresses ) } else if (targetNamespace === 'tron') { // Check Tron connection const tronAccount = this.tronService.getAccount() if (tronAccount) { currentAddress = tronAccount } else { // Try to get existing connection without prompting const walletUuid = provider.namespaceMetaData?.tron?.detectedWallet?.uuid if (walletUuid) { const wallet = this.tronService.findWalletByUuid(walletUuid) if (wallet?.provider) { const address = await this.tronService.checkExistingConnection( wallet.provider ) if (address) { currentAddress = address this.tronService.setConnectionState(wallet.provider, address) } } } } // Add Tron addresses if (currentAddress) { const tronAddresses = this.tronService.buildAvailableAddresses( provider.supportedNetworkIds, currentAddress ) availableAddresses.push(...tronAddresses) } await this.checkAndAddOtherNamespaceAddresses( 'tron', provider, availableAddresses ) } else if (targetNamespace === 'tvm') { const tonAccount = this.tonService.getAccount() if (tonAccount) { currentAddress = tonAccount } else { const existingAddress = await this.tonService.checkExistingConnection() if (existingAddress) { currentAddress = existingAddress this.tonService.setConnectionState(existingAddress) } } if (currentAddress) { const tonAddresses = this.tonService.buildAvailableAddresses( provider.supportedNetworkIds, currentAddress ) availableAddresses.push(...tonAddresses) } await this.checkAndAddOtherNamespaceAddresses( 'tvm', provider, availableAddresses ) } return { address: currentAddress, availableAddresses } } /** * Check and add Ethereum addresses if connected */ private async checkAndAddEthereumAddresses( provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider, availableAddresses: AvailableAddress[] ): Promise { let ethAccount = this.ethereumService.getAccount() // If not connected, try to check existing connection if (!ethAccount) { const ethWalletUuid = provider.namespaceMetaData?.eip155?.detectedWallet?.uuid if (ethWalletUuid) { const ethWallet = this.ethereumService.findWalletByUuid(ethWalletUuid) if (ethWallet?.provider) { try { // Parity with the Solana/Tron/TON gathers: fall back to an explicit // connect when the origin isn't already EVM-permitted. const accounts = await this.ethereumService.checkExistingConnection( ethWallet.provider ) const ethAddress = accounts && accounts.length > 0 ? accounts[0] : skipFramedSecondaryConnect() ? undefined : await this.ethereumService.connect(ethWallet.provider) if (ethAddress) { ethAccount = ethAddress this.ethereumService.setConnectionState( ethWallet.provider, ethAddress ) } } catch { // EVM unavailable or the user declined the connect prompt — // continue without it (same as the Solana/Tron/TON gathers). } } } } // Add Ethereum addresses if connected if (ethAccount) { const ethAddresses = this.ethereumService.buildAvailableAddresses( provider.supportedNetworkIds, ethAccount ) availableAddresses.push(...ethAddresses) } } /** * Check and add Solana addresses if connected */ private async checkAndAddSolanaAddresses( provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider, availableAddresses: AvailableAddress[] ): Promise { let solanaAccount = this.solanaService.getAccount() // If not connected, try to check existing connection if (!solanaAccount) { const solanaMeta = provider.namespaceMetaData?.solana const solanaWalletUuid = solanaMeta?.detectedWallet?.uuid if (solanaWalletUuid) { // Legacy-`injectedId` wallets inside the wallet's own browser (framed // OR top-level in-app) get NO secondary Solana connect (eager or // silent reconnect) — see skipLegacyInjectedSolanaSecondaryConnect. const skipLegacyConnect = skipLegacyInjectedSolanaSecondaryConnect( solanaMeta, this.usingIntegratedBrowser ) const solanaWallet = this.solanaService.findWalletByUuid(solanaWalletUuid) if (solanaWallet?.adapter) { try { const existingAddress = await this.solanaService.checkExistingConnection( solanaWallet.adapter, solanaWalletUuid, { allowSilentReconnect: !skipLegacyConnect } ) const canConnect = !skipLegacyConnect && !skipFramedSecondaryConnect(/* namespaceSafeFramed */ true) const solanaAddress = existingAddress || (canConnect ? await this.solanaService.connect( solanaWallet.adapter, solanaWalletUuid ) : undefined) if (solanaAddress) { solanaAccount = solanaAddress this.solanaService.setConnectionState( solanaWallet.adapter, solanaAddress, solanaWalletUuid ) if (skipLegacyConnect && solanaMeta) { // Gate active but the provider was ALREADY authorized — no // connect was made; a hang after this is wallet-side state. this.reportSecondarySolanaConnectSkipped( solanaMeta, 'alreadyConnected' ) } else if (solanaMeta?.injectedId) { // Gate OFF and a legacy-`injectedId` secondary Solana address // was gathered — the ONC-231 positive signal (filter // `framed:true` in prod to confirm framed clients recovered). this.reportSecondarySolanaConnectGathered( solanaMeta, existingAddress ? 'existing' : 'connect' ) } } else if (skipLegacyConnect && solanaMeta) { // The gate suppressed a would-be connect — surface it. this.reportSecondarySolanaConnectSkipped( solanaMeta, 'connectSuppressed' ) } } catch { // Solana namespace not available — continue without it } } } } // Add Solana addresses if connected if (solanaAccount) { const solanaAddresses = this.solanaService.buildAvailableAddresses( provider.supportedNetworkIds, solanaAccount ) availableAddresses.push(...solanaAddresses) } } /** * Check and add Tron addresses if connected */ private async checkAndAddTronAddresses( provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider, availableAddresses: AvailableAddress[] ): Promise { let tronAccount = this.tronService.getAccount() // If not connected, try to check existing connection if (!tronAccount) { const tronWalletUuid = provider.namespaceMetaData?.tron?.detectedWallet?.uuid if (tronWalletUuid) { const tronWallet = this.tronService.findWalletByUuid(tronWalletUuid) if (tronWallet?.provider) { try { const address = (await this.tronService.checkExistingConnection( tronWallet.provider )) || (skipFramedSecondaryConnect(/* namespaceSafeFramed */ true) ? undefined : await this.tronService.connect(tronWallet.provider)) if (address) { tronAccount = address this.tronService.setConnectionState(tronWallet.provider, address) } } catch { // Tron namespace not available — continue without it } } } } // Add Tron addresses if connected if (tronAccount) { const tronAddresses = this.tronService.buildAvailableAddresses( provider.supportedNetworkIds, tronAccount ) availableAddresses.push(...tronAddresses) } } /** * Check and add TON addresses if connected, or auto-connect if detected. * This will prompt the wallet approval dialog if TON is not yet connected — * same behavior as Solana/Tron cross-namespace auto-connect. */ private async checkAndAddTonAddresses( provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider, availableAddresses: AvailableAddress[] ): Promise { let tonAccount = this.tonService.getAccount() if (!tonAccount) { tonAccount = await this.tonService.checkExistingConnection() } if (!tonAccount) { const jsBridgeKey = provider.namespaceMetaData?.tvm?.detectedWallet?.jsBridgeKey if (jsBridgeKey && !skipFramedSecondaryConnect()) { try { const result = await this.tonService.connect(jsBridgeKey) tonAccount = result.address } catch { // TON connection failed or was rejected — continue without TON } } } if (tonAccount) { const tonAddresses = this.tonService.buildAvailableAddresses( provider.supportedNetworkIds, tonAccount ) availableAddresses.push(...tonAddresses) } } /** * After connecting one namespace, check and add addresses for all OTHER namespaces. * E.g. after connecting Ethereum, also gather Solana, Tron, and TON addresses. */ private async checkAndAddOtherNamespaceAddresses( connectedNamespace: 'eip155' | 'solana' | 'tron' | 'tvm', provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider, availableAddresses: AvailableAddress[] ): Promise { if (connectedNamespace !== 'eip155') { await this.checkAndAddEthereumAddresses(provider, availableAddresses) } if (connectedNamespace !== 'solana') { await this.checkAndAddSolanaAddresses(provider, availableAddresses) } if (connectedNamespace !== 'tron') { await this.checkAndAddTronAddresses(provider, availableAddresses) } if (connectedNamespace !== 'tvm') { await this.checkAndAddTonAddresses(provider, availableAddresses) } } /** * Collect addresses from all currently-connected namespaces. * Unlike checkAndAdd* helpers, this does NOT attempt new connections — * it only gathers addresses for namespaces already connected in this session. */ private collectConnectedAddresses( provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider ): AvailableAddress[] { const addresses: AvailableAddress[] = [] const ethAccount = this.ethereumService.getAccount() if (ethAccount) { addresses.push( ...this.ethereumService.buildAvailableAddresses( provider.supportedNetworkIds, ethAccount ) ) } const solanaAccount = this.solanaService.getAccount() if (solanaAccount) { addresses.push( ...this.solanaService.buildAvailableAddresses( provider.supportedNetworkIds, solanaAccount ) ) } const tronAccount = this.tronService.getAccount() if (tronAccount) { addresses.push( ...this.tronService.buildAvailableAddresses( provider.supportedNetworkIds, tronAccount ) ) } const tonAccount = this.tonService.getAccount() if (tonAccount) { addresses.push( ...this.tonService.buildAvailableAddresses( provider.supportedNetworkIds, tonAccount ) ) } return addresses } /** * Connect to Ethereum wallet */ async connectEthereum( network: Network, provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider ): Promise { try { const walletUuid = provider.namespaceMetaData?.eip155?.detectedWallet?.uuid if (!walletUuid) { throw new Error( `${provider.namespaceMetaData.eip155?.eip155Name || provider.namespaceMetaData.eip155?.injectedId} wallet eip155 provider was not detected` ) } const wallet = this.ethereumService.findWalletByUuid(walletUuid) if (!wallet || !wallet.provider) { throw new Error(`Wallet provider not found for UUID: ${walletUuid}`) } const address = await this.ethereumService.connect(wallet.provider) this.currentNetworkId = network.id const availableAddresses: AvailableAddress[] = [] // Add all Ethereum addresses const ethAddresses = this.ethereumService.buildAvailableAddresses( provider.supportedNetworkIds, address ) availableAddresses.push(...ethAddresses) await this.checkAndAddOtherNamespaceAddresses( 'eip155', provider, availableAddresses ) return { networkId: network.id, address, availableAddresses } } catch (error) { parseError(error) } } /** * Connect to Solana wallet */ async connectSolana( network: Network, provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider ): Promise { try { const walletUuid = provider.namespaceMetaData?.solana?.detectedWallet?.uuid if (!walletUuid) { throw new Error( `${provider.namespaceMetaData.solana?.walletStandardName} wallet solana provider was not detected` ) } const wallet = this.solanaService.findWalletByUuid(walletUuid) if (!wallet || !wallet.adapter) { throw new Error(`Wallet adapter not found for UUID: ${walletUuid}`) } const address = await this.solanaService.connect( wallet.adapter, walletUuid ) this.currentNetworkId = network.id const availableAddresses: AvailableAddress[] = [] // Add all Solana addresses const solanaAddresses = this.solanaService.buildAvailableAddresses( provider.supportedNetworkIds, address ) availableAddresses.push(...solanaAddresses) await this.checkAndAddOtherNamespaceAddresses( 'solana', provider, availableAddresses ) return { networkId: network.id, address, availableAddresses } } catch (error) { parseError(error) } } /** * Connect to Tron wallet */ async connectTron( network: Network, provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider ): Promise { try { const walletUuid = provider.namespaceMetaData?.tron?.detectedWallet?.uuid if (!walletUuid) { throw new Error( `${provider.namespaceMetaData.tron?.injectedId} wallet tron provider was not detected` ) } const wallet = this.tronService.findWalletByUuid(walletUuid) if (!wallet || !wallet.provider) { throw new Error(`Wallet provider not found for UUID: ${walletUuid}`) } const address = await this.tronService.connect(wallet.provider) this.currentNetworkId = network.id const availableAddresses: AvailableAddress[] = [] // Add all Tron addresses const tronAddresses = this.tronService.buildAvailableAddresses( provider.supportedNetworkIds, address ) availableAddresses.push(...tronAddresses) await this.checkAndAddOtherNamespaceAddresses( 'tron', provider, availableAddresses ) return { networkId: network.id, address, availableAddresses } } catch (error) { parseError(error) } } /** * Connect to TON wallet */ async connectTon( network: Network, provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider ): Promise { try { const jsBridgeKey = provider.namespaceMetaData?.tvm?.detectedWallet?.jsBridgeKey if (!jsBridgeKey) { throw new Error('TON wallet jsBridgeKey not found in provider metadata') } const connectResult = await this.tonService.connect(jsBridgeKey) this.currentNetworkId = network.id const availableAddresses: AvailableAddress[] = [] // Add all TON addresses const tonAddresses = this.tonService.buildAvailableAddresses( provider.supportedNetworkIds, connectResult.address ) availableAddresses.push(...tonAddresses) await this.checkAndAddOtherNamespaceAddresses( 'tvm', provider, availableAddresses ) return { networkId: network.id, address: connectResult.address, ...(connectResult.publicKey ? { publicKey: connectResult.publicKey } : {}), availableAddresses } } catch (error) { parseError(error) } } /** * Switch network */ async switchNetwork( network: Network, provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider ): Promise { try { const targetNamespace = network.namespace // Check if switching to a different namespace const isEthereumConnected = this.ethereumService.getConnectedProvider() !== null const isSolanaConnected = this.solanaService.getConnectedAdapter() !== null const isTronConnected = this.tronService.getConnectedProvider() !== null // If switching to eip155 but not connected, connect to Ethereum if (targetNamespace === 'eip155' && !isEthereumConnected) { const connectResult = await this.connectEthereum(network, provider) return { networkId: connectResult.networkId, address: connectResult.address, availableAddresses: connectResult.availableAddresses } } // If switching to solana but not connected, connect to Solana if (targetNamespace === 'solana' && !isSolanaConnected) { const connectResult = await this.connectSolana(network, provider) return { networkId: connectResult.networkId, address: connectResult.address, availableAddresses: connectResult.availableAddresses } } // If switching to tron but not connected, connect to Tron if (targetNamespace === 'tron' && !isTronConnected) { const connectResult = await this.connectTron(network, provider) return { networkId: connectResult.networkId, address: connectResult.address, availableAddresses: connectResult.availableAddresses } } // If switching to ton but not connected, connect to TON if (targetNamespace === 'tvm' && !this.tonService.isConnected()) { const connectResult = await this.connectTon(network, provider) return { networkId: connectResult.networkId, address: connectResult.address, availableAddresses: connectResult.availableAddresses } } // Same-namespace switching if (targetNamespace === 'eip155') { const supportsAddingNetworks = provider.namespaceMetaData?.eip155?.supportsAddingNetworks ?? true await this.ethereumService.switchNetwork( network.id, supportsAddingNetworks ) } else if ( targetNamespace !== 'solana' && targetNamespace !== 'tron' && targetNamespace !== 'tvm' ) { throw new Error( `Cannot switch to namespace '${network.namespace}'. Only 'eip155', 'solana', 'tron', and 'tvm' are currently supported.` ) } this.currentNetworkId = network.id return { networkId: network.id, address: this.getCurrentAccount() || '', availableAddresses: this.collectConnectedAddresses(provider) } } catch (error) { parseError(error) } } /** * Get current network ID */ getCurrentNetworkId(): string | null { return this.currentNetworkId } /** * Set current network ID */ setCurrentNetworkId(networkId: string | null): void { this.currentNetworkId = networkId } /** * Get current account based on current network */ getCurrentAccount(): string | null { if (this.currentNetworkId) { const namespace = this.currentNetworkId.split(':')[0] if (namespace === 'eip155') { return this.ethereumService.getAccount() } else if (namespace === 'solana') { return this.solanaService.getAccount() } else if (namespace === 'tron') { return this.tronService.getAccount() } else if (namespace === 'tvm') { return this.tonService.getAccount() } } return null } /** * Disconnect all */ async disconnect(): Promise { this.ethereumService.disconnect() this.solanaService.disconnect() this.tronService.disconnect() // async — sdk.disconnect() sends a message to the wallet via the // JS Bridge protocol, so the wallet knows to end the session. await this.tonService.disconnect() this.currentNetworkId = null } }