import type { Network, NetworkId, ExtensionInjectedProvider, IntegratedBrowserInjectedProvider, Connector, ConnectorResult, AvailableAddress, SwitchNetworkResult, Namespace, DetectedEIP6963WalletInfo, DetectedSolanaWalletInfo, DetectedTronWalletInfo, DetectedTonWalletInfo, TransactionRequest, TransactionResult, TonNativeTransferRequest, NetworkRpcMap, EVMCapabilities, SignatureType, EIP712TypedData, WalletMetadata, TonConnectConfig, WalletLifecycleEvent, WalletLifecycleContext } from '@meshconnect/uwc-types' import { assertSameMessage } from '@meshconnect/uwc-types' import { TronConnector, type TronConnectorConfig } from '@meshconnect/uwc-tron-connector' import { Transaction, VersionedTransaction } from '@solana/web3.js' import type { StandardWalletAdapter } from '@solana/wallet-standard-wallet-adapter-base' import { BridgeChild } from '@meshconnect/uwc-bridge-child' import { EthereumWalletService } from './services/ethereum/ethereum-wallet-service' import { SolanaWalletService } from './services/solana/solana-wallet-service' import { TronWalletService } from './services/tron/tron-wallet-service' import { TonWalletService } from './services/ton/ton-wallet-service' import { StorageService } from './services/storage-service' import { ConnectionManager } from './services/connection-manager' import type { SecondarySolanaConnectSkippedInfo, SecondarySolanaConnectGatheredInfo } from './services/connection-manager' import { SignatureService } from './services/signature-service' import { TransactionService } from './services/transaction-service' import type { ExtendedStandardWalletAdapter, LegacyInjectedSolanaDiscoveryFailure } from './wallet-standard-discovery' import { parseError } from './utils/error-utils' import { resolveSigningAccount } from './utils/solana-account-utils' /** * Constructor options for {@link InjectedConnector}. A single typed bag instead * of positional args: the two optional trailing callbacks (`onBridgeError`, * `onLegacyDiscoveryFailure`) occupy the same positional slot shape, so * positional passing risked wiring one where the other was expected — named * fields remove the ambiguity (the same reason the WalletConnect connector * moved to an options object). */ export interface InjectedConnectorOptions { networkRpcMap?: NetworkRpcMap | undefined expectedWallets?: WalletMetadata[] | undefined tonConnectConfig?: TonConnectConfig | undefined tronConnectorConfig?: TronConnectorConfig | undefined networks?: Network[] | undefined /** Optional sink for BridgeChild discovery failures (wired to UWC's observer). */ onBridgeError?: | ((info: { namespace: string; message: string }) => void) | undefined /** * Whether this session runs inside a wallet's in-app browser. Selects which * injected provider's Solana name the legacy fallback surfaces. */ usingIntegratedBrowser?: boolean | undefined /** * Telemetry sink invoked when a configured legacy injected-Solana global is * present but the wrong shape (see wallet-standard-discovery). */ onLegacyDiscoveryFailure?: LegacyInjectedSolanaDiscoveryFailure | undefined /** * Triage telemetry sink (ONC-3536 follow-up): the secondary Solana gather * was skipped for a legacy-`injectedId` wallet inside the wallet's own * browser (framed or top-level in-app) so an EVM send can't be parked on * the Solana namespace (see `ConnectionManager`). */ onSecondarySolanaConnectSkipped?: | ((info: SecondarySolanaConnectSkippedInfo) => void) | undefined /** * Positive telemetry sink (ONC-231): a legacy-`injectedId` wallet's secondary * Solana address WAS gathered (see `ConnectionManager`). Verifies the framed * gather works; filter `framed:true` in prod to confirm the fix. */ onSecondarySolanaConnectGathered?: | ((info: SecondarySolanaConnectGatheredInfo) => void) | undefined /** * Telemetry sink: the wallet-switch guard released live per-namespace state * that belonged to a different wallet (see `ConnectionManager`). Carries * only the released namespaces — never an address. */ onWalletConnectionsReleased?: | ((info: { namespaces: Namespace[] }) => void) | undefined } export class InjectedConnector implements Connector { private readonly storageService: StorageService private ethereumService: EthereumWalletService private solanaService: SolanaWalletService private tronService: TronWalletService private tonService: TonWalletService private connectionManager: ConnectionManager private signatureService: SignatureService private transactionService: TransactionService private networkRpcMap: NetworkRpcMap private tonConnectManifestUrl: string | (() => string) | undefined /** * Mesh-owned Tron connector. When set (via `tronConnectorConfig`), the Tron * namespace is routed through `@meshconnect/uwc-tron-connector` instead of the * legacy `window.tronWeb` path; the `tronService` is not consulted for Tron. */ private tronConnector: TronConnector | null /** * Configured networks, used to resolve the Tron network for cross-namespace * Tron auto-connect (see `enrichWithTron`). */ private networks: Network[] /** * Selects which injected provider's Solana name the legacy fallback surfaces, * so it matches what core's `connectSolana` compares against. */ private usingIntegratedBrowser: boolean private onLegacyDiscoveryFailure?: | LegacyInjectedSolanaDiscoveryFailure | undefined /** injectedIds already reported this session — dedupes the emit across the * repeated Solana detection cycles (mirrors the bridgeError source dedup). */ private readonly reportedLegacyFailures = new Set() /** Forward a legacy Solana discovery failure to the sink, once per injectedId. */ private reportLegacyDiscoveryFailure: LegacyInjectedSolanaDiscoveryFailure = info => { if (this.reportedLegacyFailures.has(info.injectedId)) return this.reportedLegacyFailures.add(info.injectedId) this.onLegacyDiscoveryFailure?.(info) } constructor(options: InjectedConnectorOptions = {}) { const { networkRpcMap = {}, expectedWallets = [], tonConnectConfig, tronConnectorConfig, networks = [], onBridgeError, usingIntegratedBrowser = false, onLegacyDiscoveryFailure, onSecondarySolanaConnectSkipped, onSecondarySolanaConnectGathered, onWalletConnectionsReleased } = options this.networkRpcMap = networkRpcMap this.networks = networks this.usingIntegratedBrowser = usingIntegratedBrowser this.onLegacyDiscoveryFailure = onLegacyDiscoveryFailure this.tonConnectManifestUrl = tonConnectConfig?.manifestUrl this.tronConnector = tronConnectorConfig ? new TronConnector(tronConnectorConfig) : null this.storageService = new StorageService() this.ethereumService = new EthereumWalletService() this.solanaService = new SolanaWalletService(this.storageService) this.tronService = new TronWalletService() this.tonService = new TonWalletService( this.tonConnectManifestUrl, tonConnectConfig?.onSessionRestored ) this.connectionManager = new ConnectionManager( this.ethereumService, this.solanaService, this.tronService, this.tonService, { usingIntegratedBrowser, onSecondarySolanaConnectSkipped, onSecondarySolanaConnectGathered, onWalletConnectionsReleased } ) this.signatureService = new SignatureService() this.transactionService = new TransactionService(this.networkRpcMap) new BridgeChild({ tronInjectedIds: this.tronService.getTronWalletIdsFromExpectedWallets(expectedWallets), tonJsBridgeKeys: this.tonService.getTonBridgeKeysFromExpectedWallets(expectedWallets), ...(onBridgeError ? { onError: onBridgeError } : {}) }) } /** * Get available wallets for a specific namespace */ async getAvailableWallets( namespace: Namespace, expectedWallets: WalletMetadata[] = [] ): Promise< | DetectedEIP6963WalletInfo[] | DetectedSolanaWalletInfo[] | DetectedTronWalletInfo[] | DetectedTonWalletInfo[] > { switch (namespace) { case 'eip155': { const detectedWallets = this.ethereumService.getDetectedWallets() // Re-fetch if no wallets detected if (detectedWallets.length === 0) { await this.ethereumService.initializeDiscovery(expectedWallets) } // Convert to DetectedWalletInfo format return this.ethereumService.getDetectedWallets().map(wallet => ({ uuid: wallet.uuid, name: wallet.name, icon: wallet.icon, rdns: wallet.rdns })) } case 'solana': { // Re-fetch Solana wallets await this.solanaService.initializeDiscovery( expectedWallets, this.usingIntegratedBrowser, this.onLegacyDiscoveryFailure ? this.reportLegacyDiscoveryFailure : undefined ) // Convert to DetectedWalletInfo format return this.solanaService.getDetectedWallets().map(wallet => ({ uuid: wallet.uuid, name: wallet.name, features: wallet.features, // Include the flag only for legacy-fallback entries — registry wallets // leave it absent (never `legacyInjected: undefined`), matching the // "absent = registry" contract and exactOptionalPropertyTypes. ...(wallet.legacyInjected !== undefined && { legacyInjected: wallet.legacyInjected }) })) } case 'tron': { if (this.tronConnector) { return (await this.tronConnector.getAvailableWallets( 'tron', expectedWallets )) as DetectedTronWalletInfo[] } await this.tronService.initializeDiscovery(expectedWallets) return this.tronService.getDetectedWallets().map(wallet => ({ uuid: wallet.uuid, name: wallet.name })) } case 'tvm': { await this.tonService.initializeDiscovery(expectedWallets) return this.tonService.getDetectedWallets().map(wallet => ({ uuid: wallet.uuid, name: wallet.name, icon: wallet.icon, jsBridgeKey: wallet.jsBridgeKey })) } default: return [] } } /** * Connect to a wallet */ async connect( network: Network, provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider ): Promise { const targetNamespace = network.namespace // Check if we're already connected const { address: existingAddress, availableAddresses } = await this.connectionManager.checkExistingConnections( targetNamespace as 'eip155' | 'solana' | 'tron' | 'tvm', provider ) let result: ConnectorResult if (existingAddress) { // Already connected — return the existing connection. this.connectionManager.setCurrentNetworkId(network.id) result = { networkId: network.id, address: existingAddress, availableAddresses } } else { // Not connected, need to establish a new connection. result = await this.connectToNamespace(targetNamespace, network, provider) } // Cross-namespace Tron auto-connect. When the Tron connector owns the Tron // namespace, the legacy connection-manager gather is dormant, so a // multi-namespace wallet (Trust, OKX, …) connected on EVM/Solana would // otherwise never expose its Tron address. Mirror the ETH/Solana/TON // gathers and connect Tron too (skipped when Tron is the connect target — // that path connects Tron directly above). if (targetNamespace !== 'tron') { await this.enrichWithTron(provider, result.availableAddresses) } return result } /** Establish a fresh connection for the target namespace. */ private async connectToNamespace( targetNamespace: Namespace, network: Network, provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider ): Promise { switch (targetNamespace) { case 'eip155': return await this.connectionManager.connectEthereum(network, provider) case 'solana': return await this.connectionManager.connectSolana(network, provider) case 'tron': if (this.tronConnector) { // Pass the provider through — the Tron connector reads the wallet's // `namespaceMetaData.tron.injectedId` from it (metadata-driven). const result = await this.tronConnector.connect(network, provider) this.connectionManager.setCurrentNetworkId(network.id) return result } return await this.connectionManager.connectTron(network, provider) case 'tvm': return await this.connectionManager.connectTon(network, provider) default: throw new Error( `Namespace '${network.namespace}' is not implemented yet. Only 'eip155', 'solana', 'tron', and 'tvm' are currently supported.` ) } } /** * Cross-namespace Tron gather: when the Tron connector is configured and the * wallet exposes a detected Tron provider, connect Tron and merge its address * into `availableAddresses` — the Tron equivalent of the ETH/Solana/TON * gathers in `ConnectionManager`. Best-effort: a missing Tron network, an * undetected wallet, or a declined prompt leaves the rest of the connection * intact (same as the other gathers, which swallow their failures). */ private async enrichWithTron( provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider, availableAddresses: AvailableAddress[] ): Promise { if (!this.tronConnector) return // The eager Tron gather runs even when framed. The Tron connector is // bridge-aware (ONC-3383, #176): when a `BridgeParent` is present it resolves // the provider from `window.tronWallets` and routes the connect + the catalog's // follow-up ownership-verification signature to the TOP frame via Comlink, so // Trust Wallet's frame-position gate (`dapp.frames-disallowed`) is satisfied — // exactly like the EVM/Solana bridged paths. This mirrors the framed Solana // gather (see `skipFramedSecondaryConnect`); an earlier blanket framed skip // dropped a multi-namespace wallet's Tron address (e.g. Trust connected on EVM // never surfaced Tron). If Tron is unavailable or the bridge is absent, the // connect is caught below and the gather degrades gracefully. // The wallet must declare a Tron injected provider in its metadata. if (!provider.namespaceMetaData?.tron?.injectedId) return // Already gathered (e.g. a re-entrant connect) — don't reprompt. if (availableAddresses.some(a => a.networkId.startsWith('tron:'))) return const tronNetwork = this.networks.find( n => n.namespace === 'tron' && provider.supportedNetworkIds.includes(n.id) ) if (!tronNetwork) return try { // Pass the provider — the Tron connector reads its tron.injectedId. const result = await this.tronConnector.connect(tronNetwork, provider) for (const addr of result.availableAddresses) { const dup = availableAddresses.some( a => a.networkId === addr.networkId && a.address === addr.address ) if (!dup) availableAddresses.push(addr) } } catch { // Tron unavailable or the user declined the prompt — continue without it, // same as the Solana/Tron/TON gathers in ConnectionManager. } } /** * Switch to a different network */ async switchNetwork( network: Network, provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider ): Promise { if (this.tronConnector && network.namespace === 'tron') { const result = await this.tronConnector.switchNetwork(network) // Keep ConnectionManager's current network id in sync so subsequent // signMessage/sendTransaction namespace routing isn't left stale. this.connectionManager.setCurrentNetworkId(network.id) return result } return await this.connectionManager.switchNetwork(network, provider) } /** * Disconnect from all wallets (only clears local state) */ async disconnect(): Promise { if (this.tronConnector) { // Best-effort: don't fail the overall disconnect if the connector throws. try { await this.tronConnector.disconnect() } catch { // ignore } } await this.connectionManager.disconnect() } /** * Observe post-connect lifecycle from the live provider of the namespace the * CALLER names in `context`. Routing must follow the session being attached * for, not a fixed service preference or ambient connector state: the * secondary-namespace gather (`checkAndAdd*Addresses`) can leave MORE than * one service holding a live surface — e.g. a Solana-primary Phantom session * with an authorized EVM side also sets the Ethereum service's provider — and * attaching the wrong one would label that provider's events with this * session's routing (an EVM chain switch reported against a Solana session) * and miss the active namespace's own signals. * * EVM (EIP-1193) and Solana (wallet-standard) have a live event surface today; * TRON / TON injected are follow-ons behind the same contract (until then they * return undefined → the core emits `lifecycleTelemetryUnavailable`). */ onLifecycleEvent( listener: (event: WalletLifecycleEvent) => void, context: WalletLifecycleContext ): (() => void) | undefined { switch (context.namespace) { case 'eip155': return this.ethereumService.onLifecycleEvent(listener) case 'solana': return this.solanaService.onLifecycleEvent(listener) default: return undefined } } /** * Sign a message with the connected wallet */ async signMessage( message: string, provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider ): Promise { if (!provider) { throw new Error('Provider is required for message signing') } if (!message) { throw new Error('Message is required for signing') } // Get current network ID to determine namespace const currentNetworkId = this.connectionManager.getCurrentNetworkId() if (!currentNetworkId) { throw new Error('No active network connection') } // Determine namespace from network ID (format: "namespace:chainId") const namespace = currentNetworkId.split(':')[0] as | 'eip155' | 'solana' | 'tron' | 'tvm' // Get current address for the namespace const currentAddress = this.connectionManager.getCurrentAccount() if (namespace === 'eip155') { if (!currentAddress) { throw new Error('No active wallet address for signing') } // Get the actual Ethereum provider const ethProvider = this.ethereumService.getConnectedProvider() if (!ethProvider) { throw new Error('No connected Ethereum provider') } const signature = await this.signatureService.signEthereumMessage( message, currentAddress, ethProvider ) return { type: 'standard', signature: signature } } else if (namespace === 'solana') { // Get the actual Solana adapter const solanaAdapter = this.solanaService.getConnectedAdapter() if (!solanaAdapter) { throw new Error('No connected Solana adapter') } const signature = await this.signatureService.signSolanaMessage( message, solanaAdapter ) return { type: 'standard', signature: signature } } else if (namespace === 'tron') { if (this.tronConnector) { // TronConnector.signMessage already returns a SignatureType // ({ type: 'standard', signature }) — return it as-is, no re-wrap. return await this.tronConnector.signMessage(message) } const tronProvider = this.tronService.getConnectedProvider() if (!tronProvider) { throw new Error('No connected Tron provider') } const signature = await this.signatureService.signTronMessage( message, tronProvider ) return { type: 'standard', signature: signature } } else if (namespace === 'tvm') { return await this.tonService.signMessage(message) } else { throw new Error(`Signing not supported for namespace: ${namespace}`) } } /** * Sign EIP-712 typed structured data (eth_signTypedData_v4). * EVM-only (eip155); used by ERC-3009 and EIP-2612 relay flows. The injected * connector owns its own provider reference (resolved from ethereumService), * so the optional `_provider` argument from the Connector interface is unused. */ async signTypedData( typedData: EIP712TypedData, _provider?: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider ): Promise { const currentNetworkId = this.connectionManager.getCurrentNetworkId() if (!currentNetworkId) { throw new Error('No active network connection') } const namespace = currentNetworkId.split(':')[0] if (namespace !== 'eip155') { throw new Error( `signTypedData is only supported for EVM wallets, got: ${namespace}` ) } const currentAddress = this.connectionManager.getCurrentAccount() if (!currentAddress) { throw new Error('No active wallet address for signing') } const ethProvider = this.ethereumService.getConnectedProvider() if (!ethProvider) { throw new Error('No connected Ethereum provider') } return this.signatureService.signEthereumTypedData( typedData, currentAddress, ethProvider ) } /** * Send a transaction with the connected wallet */ async sendTransaction( request: TransactionRequest, provider: ExtensionInjectedProvider | IntegratedBrowserInjectedProvider ): Promise { if (!provider) { throw new Error('Provider is required for sending transactions') } // Get current network ID to determine namespace const currentNetworkId = this.connectionManager.getCurrentNetworkId() if (!currentNetworkId) { throw new Error('No active network connection') } // Determine namespace from network ID (format: "namespace:chainId") const namespace = currentNetworkId.split(':')[0] as | 'eip155' | 'solana' | 'tron' | 'tvm' if (namespace === 'eip155') { // Get the actual Ethereum provider const ethProvider = this.ethereumService.getConnectedProvider() if (!ethProvider) { throw new Error('No connected Ethereum provider') } return await this.transactionService.sendTransaction( request, namespace, ethProvider ) } else if (namespace === 'solana') { // Get the actual Solana adapter const solanaAdapter = this.solanaService.getConnectedAdapter() if (!solanaAdapter) { throw new Error('No connected Solana adapter') } return await this.transactionService.sendTransaction( request, namespace, solanaAdapter, currentNetworkId as NetworkId ) } else if (namespace === 'tron') { if (this.tronConnector) { return await this.tronConnector.sendTransaction(request) } const tronProvider = this.tronService.getConnectedProvider() if (!tronProvider) { throw new Error('No connected Tron provider') } return await this.transactionService.sendTransaction( request, namespace, tronProvider ) } else if (namespace === 'tvm') { return await this.tonService.sendTransaction( request as TonNativeTransferRequest ) } else { throw new Error(`Transactions not supported for namespace: ${namespace}`) } } /** * Get EVM wallet capabilities (EIP-5792). * Only valid for eip155 connections — non-EVM wallets don't support * wallet_getCapabilities and the hex address format it requires. */ async getWalletCapabilities( from: string, networks: Network[] ): Promise> { const currentNetworkId = this.connectionManager.getCurrentNetworkId() const namespace = currentNetworkId?.split(':')[0] if (namespace !== 'eip155') { return {} } return await this.ethereumService.getCapabilities(from, networks) } /** * Sign serialized Solana tx bytes without broadcasting (raw bytes in/out, no * @solana/web3.js for callers). Tries: bridge (iframe) → Wallet Standard * feature → legacy adapter. The feature path avoids Transaction.serialize(), * which crashes some mobile WebViews. Errors go through parseError (typed * "rejected" on user cancel). */ async signSolanaTransactionBytes( serializedTx: Uint8Array ): Promise { const currentNetworkId = this.connectionManager.getCurrentNetworkId() if (!currentNetworkId?.startsWith('solana:')) { throw new Error( 'signSolanaTransactionBytes requires an active Solana connection' ) } const solanaAdapter = this.solanaService.getConnectedAdapter() if (!solanaAdapter) { throw new Error('No connected Solana adapter') } try { // Path 1: bridge (iframe). The wallet lives on the parent; forward bytes. // `await customFunctions` (not a truthy check) — Comlink proxies every // property as truthy, so the awaited list is the only reliable signal. const extendedAdapter = solanaAdapter as ExtendedStandardWalletAdapter const customFns = await extendedAdapter.customFunctions if (customFns?.includes('signSolanaTransactionBytes')) { // Sign with the child's connected account — the parent's publicKey may // have drifted. const signed = await extendedAdapter.signSolanaTransactionBytes( serializedTx, this.connectionManager.getCurrentAccount() ?? undefined ) // Verify in the iframe (trusted), not on the parent, so a tampered // parent can't bypass it. Byte-stable round-trip → safe for the bridge's // legacy sub-path too (see the "byte-stable" test). assertSameMessage(serializedTx, signed) return signed } // Path 2: Wallet Standard solana:signTransaction (raw bytes in/out). const wallet = (solanaAdapter as StandardWalletAdapter).wallet const SIGN_TX = 'solana:signTransaction' if (wallet && SIGN_TX in wallet.features && wallet.accounts.length > 0) { const account = resolveSigningAccount( wallet.accounts, this.connectionManager.getCurrentAccount() ) const feature = wallet.features[SIGN_TX] const [output] = await feature.signTransaction({ account, transaction: serializedTx }) if (!output?.signedTransaction) { throw new Error('Wallet returned no signed transaction') } // Reject a tampered message before the relay broadcasts. assertSameMessage(serializedTx, output.signedTransaction) return output.signedTransaction } // Path 3: legacy adapter (no .wallet getter). if (!solanaAdapter.signTransaction) { throw new Error('Connected wallet does not support signTransaction') } // V0 + legacy. requireAllSignatures:false — the relay fills the fee-payer slot. let transaction: Transaction | VersionedTransaction try { transaction = VersionedTransaction.deserialize(serializedTx) } catch { try { transaction = Transaction.from(serializedTx) } catch { throw new Error( 'Failed to deserialize transaction as either versioned or legacy format' ) } } const signed = await solanaAdapter.signTransaction(transaction) const signedBytes = signed instanceof VersionedTransaction ? signed.serialize() : new Uint8Array( signed.serialize({ requireAllSignatures: false, verifySignatures: false }) ) // Same guard as Path 1/2. The deserialize→sign→reserialize round-trip is // byte-stable (see the "byte-stable" tests), so this rejects a tampering // adapter without false-positiving a legit sign. assertSameMessage(serializedTx, signedBytes) return signedBytes } catch (err) { // Normalize: turns a wallet rejection (EIP-1193 4001 / "user rejected") // into a WalletConnectorError{ type: 'rejected' } so the Link UI can tell // "user cancelled" apart from a real failure. parseError always throws. throw parseError(err) } } }