import * as Comlink from 'comlink' import type { Wallet } from '@wallet-standard/base' import { getWallets } from '@wallet-standard/app' import { isWalletAdapterCompatibleStandardWallet, WalletReadyState } from '@solana/wallet-adapter-base' import { Connection, Transaction, VersionedTransaction } from '@solana/web3.js' import { StandardWalletAdapter } from '@solana/wallet-standard-wallet-adapter-base' import { BaseWalletAdapter } from './BaseWalletAdapter' import { signSolanaBytes } from './signSolanaBytes' // The listener type Comlink's expose() passes to endpoint.addEventListener. type EndpointAddEventListenerOptions = Parameters< Comlink.Endpoint['addEventListener'] >[2] type EndpointMessageListener = Parameters< Comlink.Endpoint['addEventListener'] >[1] // Only run the wrapped listener for messages from the mesh iframe's window. // comlink validates event.origin but never event.source, so this closes the // same-origin sibling-frame gap. Real bridge traffic always carries // source === the iframe's contentWindow. function wrapWithSourceGuard( listener: EndpointMessageListener, expectedSource: Window | null ): EndpointMessageListener { return (event: Event): void => { if ((event as MessageEvent).source !== expectedSource) { return } if (typeof listener === 'function') { // Preserve native dispatch semantics (this === currentTarget). listener.call(event.currentTarget, event) } else { listener.handleEvent(event) } } } export interface EthereumProvider { // eslint-disable-next-line @typescript-eslint/no-explicit-any request: (args: { method: string; params?: unknown[] }) => Promise // eslint-disable-next-line @typescript-eslint/no-explicit-any on?: (event: string, handler: (...args: any[]) => void) => void // eslint-disable-next-line @typescript-eslint/no-explicit-any removeListener?: (event: string, handler: (...args: any[]) => void) => void } interface ExtendedStandardWalletAdapter extends StandardWalletAdapter { customFunctions: ( | 'sendSerializedTransaction' | 'signSerializedTransaction' | 'signAllSerializedTransactions' | 'signSolanaTransactionBytes' )[] sendSerializedTransaction: ( transaction: ArrayBufferLike, rpcUrl: string ) => Promise signSerializedTransaction: ( transaction: ArrayBufferLike ) => Promise signAllSerializedTransactions: ( transactions: ArrayBufferLike[] ) => Promise signSolanaTransactionBytes: ( transaction: ArrayBufferLike, connectedAddress?: string ) => Promise } export interface WalletStandardInfo { uuid: string name: string chains: string[] features: string[] adapter: ExtendedStandardWalletAdapter | undefined } export interface EIP6963ProviderInfo { uuid: string name: string icon: string rdns: string } export interface EIP6963ProviderDetail { info: EIP6963ProviderInfo provider: EthereumProvider } export interface EIP6963AnnounceProviderEvent extends CustomEvent { type: 'eip6963:announceProvider' detail: EIP6963ProviderDetail } export interface DetectedWallet { uuid: string name: string icon: string rdns: string provider: EthereumProvider } export interface TronWalletInfo { uuid: string name: string injectedId: string // eslint-disable-next-line @typescript-eslint/no-explicit-any provider: any } export interface TonWalletInfo { uuid: string name: string icon: string jsBridgeKey: string // eslint-disable-next-line @typescript-eslint/no-explicit-any provider: any } export interface ParentAPI { eip6963Wallets: DetectedWallet[] eip6963WalletsReady: boolean walletStandardWallets: WalletStandardInfo[] walletStandardWalletsReady: boolean tronWallets: TronWalletInfo[] tronWalletsReady: boolean tonWallets: TonWalletInfo[] tonWalletsReady: boolean parentOrigin: string discoverTronWallets: (injectedIds: string[]) => void discoverTonWallets: (jsBridgeKeys: string[]) => void } export class BridgeParent { private iframe: HTMLIFrameElement | null private parentAPI: ParentAPI | null = null private endpoint: Comlink.Endpoint | null = null // Capture each (listener, options) pair so destroy() can call // removeEventListener with the same args. Options (e.g. `{capture: true}`) // are part of the listener's identity for the DOM removal lookup; passing a // bare listener won't match a capture-phase registration. private exposeListeners: Array<{ listener: EndpointMessageListener options: EndpointAddEventListenerOptions }> = [] private destroyed = false constructor(iframe: HTMLIFrameElement) { if (!iframe) { throw new Error('BridgeParent requires an iframe element') } this.iframe = iframe this.initializeConnection() } private async initializeConnection(): Promise { // Wait for iframe to be ready if (!this.iframe || !this.iframe.contentWindow) { return } // Expose the parentAPI to the child as soon as possible. EIP-6963 // discovery takes ~100ms (it waits for announcements), but we don't want // to block the child from calling discoverTronWallets / discoverTonWallets // while that's in flight — those paths are independent. The readiness // flags gate the child from reading a still-empty array. const eip6963Promise = this.discovereip6963Wallets() // Solana is synchronous, so run it inline (no benefit to a microtask hop) const walletStandardWallets = this.getSolanaWallets() this.parentAPI = { eip6963Wallets: [], eip6963WalletsReady: false, walletStandardWallets: walletStandardWallets, walletStandardWalletsReady: true, tronWallets: [], tronWalletsReady: false, tonWallets: [], tonWalletsReady: false, parentOrigin: window.location.origin, // Called by BridgeChild to trigger Tron wallet discovery with injectedIds discoverTronWallets: (injectedIds: string[]) => { if (this.parentAPI) { this.parentAPI.tronWallets = this.getTronWallets(injectedIds) this.parentAPI.tronWalletsReady = true } }, // Called by BridgeChild to trigger TON wallet discovery with jsBridgeKeys discoverTonWallets: (jsBridgeKeys: string[]) => { if (this.parentAPI) { this.parentAPI.tonWallets = this.getTonWallets(jsBridgeKeys) this.parentAPI.tonWalletsReady = true } } } // Use Comlink's windowEndpoint with proper cross-origin support // For cross-origin, we need to listen on the parent window, not access the iframe's window const iframeOrigin = new URL(this.iframe.src, window.location.href).origin const endpoint = Comlink.windowEndpoint( this.iframe.contentWindow, window, iframeOrigin ) // initializeConnection is async and fire-and-forget; expose() currently runs // synchronously, so destroy() (only reachable after construction) always sees a // captured listener. Guard the invariant anyway: if destroy() ran while a future // await suspended us here, skip expose() so we never register a window listener // with no live owner to remove it. if (this.destroyed) { return } this.endpoint = endpoint // Captured now because destroy() nulls this.iframe; the source guard closes // over this reference. const expectedSource = this.iframe.contentWindow // Comlink's expose() registers a 'message' listener on the endpoint that it // only removes on a child RELEASE — it returns no teardown handle. Capture it // so destroy() can detach it. Otherwise a closed session leaves a stale // listener on window that, being registered before the next session's parent, // answers the child's discovery reads first (listeners fire in registration // order) with empty arrays — making injected wallets (e.g. TronLink) look // absent and forcing a QR fallback. // We register a source-guarded wrapper in comlink's place, so both sides // must be translated: store the wrapper for destroy(), and map comlink's // original listener → wrapper so comlink's own RELEASE teardown // (removeEventListener('message', original)) still detaches the wrapper. // Without the remove translation a RELEASE would leave a zombie listener. const guardedByOriginal = new WeakMap() const originalAddEventListener = endpoint.addEventListener.bind(endpoint) const originalRemoveEventListener = endpoint.removeEventListener.bind(endpoint) endpoint.addEventListener = ( type: string, listener: EndpointMessageListener, options?: EndpointAddEventListenerOptions ): void => { if (type === 'message') { const guardedListener = wrapWithSourceGuard(listener, expectedSource) guardedByOriginal.set(listener as object, guardedListener) this.exposeListeners.push({ listener: guardedListener, options }) originalAddEventListener(type, guardedListener, options) return } originalAddEventListener(type, listener, options) } endpoint.removeEventListener = ( type: string, listener: EndpointMessageListener, options?: EndpointAddEventListenerOptions ): void => { const target = type === 'message' ? (guardedByOriginal.get(listener as object) ?? listener) : listener originalRemoveEventListener(type, target, options) } // Restrict inbound RPC to the mesh iframe origin (comlink defaults to ["*"]). Comlink.expose(this.parentAPI, endpoint, [iframeOrigin]) // Populate EIP-6963 results in the background. The child polls the // ready flag; it will see them on the next read after we flip it. eip6963Promise.then(eip6963Wallets => { if (!this.parentAPI) return this.parentAPI.eip6963Wallets = eip6963Wallets this.parentAPI.eip6963WalletsReady = true }) } private async discovereip6963Wallets(): Promise { return new Promise(resolve => { const detectedWallets: DetectedWallet[] = [] const timeout = 100 // Wait 100ms for wallets to announce // Set up listener for wallet announcements const handleAnnouncement = (event: Event) => { const announcementEvent = event as EIP6963AnnounceProviderEvent const { info, provider } = announcementEvent.detail // Check if wallet is already detected (by uuid) const existingIndex = detectedWallets.findIndex( w => w.uuid === info.uuid ) if (existingIndex === -1) { detectedWallets.push({ uuid: info.uuid, name: info.name, icon: info.icon, rdns: info.rdns, provider: provider }) } } // Listen for wallet announcements window.addEventListener('eip6963:announceProvider', handleAnnouncement) // Request wallets to announce themselves window.dispatchEvent(new Event('eip6963:requestProvider')) // Clean up and resolve after timeout setTimeout(() => { window.removeEventListener( 'eip6963:announceProvider', handleAnnouncement ) resolve(detectedWallets) }, timeout) }) } private getSolanaWallets(): WalletStandardInfo[] { const { get } = getWallets() const wallets = get() const solanaWallets: WalletStandardInfo[] = [] const walletNames = new Set() // First, get wallets from Wallet Standard for (const wallet of wallets) { // Check if this is a Solana wallet if (this.isSolanaWallet(wallet)) { let adapter: StandardWalletAdapter | undefined if (isWalletAdapterCompatibleStandardWallet(wallet)) { adapter = new StandardWalletAdapter({ wallet }) } solanaWallets.push({ uuid: this.generateWalletId(wallet), name: wallet.name, chains: (wallet.chains || []) as string[], features: Object.keys(wallet.features), // Expect a TS error, as not all adapter properties are implemented // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore adapter: adapter ? { name: adapter.name, url: adapter.url, icon: adapter.icon, readyState: adapter.readyState, publicKey: Comlink.proxy({ get value() { return adapter.publicKey }, toBase58: async () => { return adapter.publicKey?.toBase58() }, toJSON: async () => { return adapter.publicKey?.toJSON() }, toBytes: async () => { return adapter.publicKey?.toBytes() }, toBuffer: async () => { return adapter.publicKey?.toBuffer() }, toString: async () => { return adapter.publicKey?.toString() } }), connecting: adapter.connecting, connected: adapter.connected, supportedTransactionVersions: adapter.supportedTransactionVersions, wallet: adapter.wallet, standard: adapter.standard, destroy: async () => await adapter.destroy(), autoConnect: async () => await adapter.autoConnect(), connect: async () => await adapter.connect(), disconnect: async () => await adapter.disconnect(), sendTransaction: async (transaction, connection, options) => await adapter.sendTransaction( transaction, connection, options ), signTransaction: async transaction => { return adapter.signTransaction !== undefined ? await adapter.signTransaction(transaction) : new Error('Adapter does not support signTransaction') }, signAllTransactions: async transactions => { return adapter.signAllTransactions !== undefined ? await adapter.signAllTransactions(transactions) : new Error('Adapter does not support signAllTransactions') }, signMessage: async message => { return adapter.signMessage !== undefined ? await adapter.signMessage(message) : new Error('Adapter does not support signMessage') }, signIn: async input => { return adapter.signIn !== undefined ? await adapter.signIn(input) : new Error('Adapter does not support signIn') }, customFunctions: [ 'sendSerializedTransaction', 'signAllSerializedTransactions', 'signSerializedTransaction', 'signSolanaTransactionBytes' ], // Sign on the parent (where the wallet is) so only bytes cross // Comlink — a Transaction object loses its prototype via // structured clone. connectedAddress (from the child) wins over // the parent publicKey, which may have drifted. signSolanaTransactionBytes: async ( transaction: ArrayBufferLike, connectedAddress?: string ): Promise => { return signSolanaBytes( adapter, new Uint8Array(transaction), connectedAddress ) }, sendSerializedTransaction: async ( transaction: ArrayBufferLike, rpcUrl: string ) => { const connection = new Connection(rpcUrl) const uint8Array = new Uint8Array(transaction) // Try to deserialize as VersionedTransaction first let deserializedTx: Transaction | VersionedTransaction try { deserializedTx = VersionedTransaction.deserialize(uint8Array) } catch { // If that fails, try as legacy Transaction try { deserializedTx = Transaction.from(uint8Array) } catch { throw new Error( 'Failed to deserialize transaction as either versioned or legacy format' ) } } return await adapter.sendTransaction( deserializedTx, connection ) }, signSerializedTransaction: async ( transaction: ArrayBufferLike ) => { if (adapter.signTransaction === undefined) { return new Error('Adapter does not support signTransaction') } const uint8Array = new Uint8Array(transaction) // Try to deserialize as VersionedTransaction first let deserializedTx: Transaction | VersionedTransaction try { deserializedTx = VersionedTransaction.deserialize(uint8Array) } catch { // If that fails, try as legacy Transaction try { deserializedTx = Transaction.from(uint8Array) } catch { return new Error( 'Failed to deserialize transaction as either versioned or legacy format' ) } } return await adapter.signTransaction(deserializedTx) }, signAllSerializedTransactions: async ( transactions: ArrayBufferLike[] ) => { if (adapter.signAllTransactions === undefined) { return new Error( 'Adapter does not support signAllTransactions' ) } const deserializedTransactions: ( | Transaction | VersionedTransaction )[] = [] for (const transaction of transactions) { const uint8Array = new Uint8Array(transaction) // Try to deserialize as VersionedTransaction first let deserializedTx: Transaction | VersionedTransaction try { deserializedTx = VersionedTransaction.deserialize(uint8Array) } catch { // If that fails, try as legacy Transaction try { deserializedTx = Transaction.from(uint8Array) } catch { return new Error( 'Failed to deserialize one or more transactions' ) } } deserializedTransactions.push(deserializedTx) } return await adapter.signAllTransactions( deserializedTransactions ) } } : undefined }) walletNames.add(wallet.name) } } // Add Base Wallet via traditional adapter (detects window.coinbaseSolana) try { const baseAdapter = new BaseWalletAdapter() const isDetected = baseAdapter.readyState === WalletReadyState.Installed || baseAdapter.readyState === WalletReadyState.Loadable if (isDetected && !walletNames.has(baseAdapter.name)) { solanaWallets.push({ uuid: `${baseAdapter.name}-traditional` .toLowerCase() .replace(/\s+/g, '-'), name: baseAdapter.name, chains: ['solana:mainnet'], features: ['traditional-adapter'], // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore adapter: { name: baseAdapter.name, url: baseAdapter.url, // @ts-expect-error icon is string but type expects template literal icon: baseAdapter.icon, readyState: baseAdapter.readyState, // @ts-expect-error publicKey is a Comlink proxy, not a real PublicKey publicKey: Comlink.proxy({ get value() { return baseAdapter.publicKey }, toBase58: async () => { return baseAdapter.publicKey?.toBase58() }, toJSON: async () => { return baseAdapter.publicKey?.toJSON() }, toBytes: async () => { return baseAdapter.publicKey?.toBytes() }, toBuffer: async () => { return baseAdapter.publicKey?.toBuffer() }, toString: async () => { return baseAdapter.publicKey?.toString() } }), connecting: baseAdapter.connecting, connected: baseAdapter.connected, supportedTransactionVersions: baseAdapter.supportedTransactionVersions, connect: async () => await baseAdapter.connect(), disconnect: async () => await baseAdapter.disconnect(), sendTransaction: async (transaction, connection, options) => { try { return await baseAdapter.sendTransaction( transaction, connection, options ) } catch { throw new Error('Failed to send transaction') } }, signTransaction: async transaction => { return await baseAdapter.signTransaction(transaction) }, signAllTransactions: async transactions => { return await baseAdapter.signAllTransactions(transactions) }, signMessage: async message => { return await baseAdapter.signMessage(message) }, customFunctions: [ 'sendSerializedTransaction', 'signAllSerializedTransactions', 'signSerializedTransaction', 'signSolanaTransactionBytes' ], // Legacy base adapter (no Wallet Standard feature) — signSolanaBytes // falls back to deserialize → sign → reserialize. See Standard // wrapper above for the connectedAddress rationale. signSolanaTransactionBytes: async ( transaction: ArrayBufferLike, connectedAddress?: string ): Promise => { return signSolanaBytes( baseAdapter, new Uint8Array(transaction), connectedAddress ) }, sendSerializedTransaction: async ( transaction: ArrayBufferLike, rpcUrl: string ) => { const connection = new Connection(rpcUrl) const uint8Array = new Uint8Array(transaction) let deserializedTx: Transaction | VersionedTransaction try { deserializedTx = VersionedTransaction.deserialize(uint8Array) } catch { try { deserializedTx = Transaction.from(uint8Array) } catch { throw new Error( 'Failed to deserialize transaction as either versioned or legacy format' ) } } try { const txHash = await baseAdapter.sendTransaction( deserializedTx, connection ) return txHash } catch { throw new Error('Failed to send serialized transaction') } }, // @ts-expect-error return type includes VersionedTransaction signSerializedTransaction: async (transaction: ArrayBufferLike) => { const uint8Array = new Uint8Array(transaction) let deserializedTx: Transaction | VersionedTransaction try { deserializedTx = VersionedTransaction.deserialize(uint8Array) } catch { try { deserializedTx = Transaction.from(uint8Array) } catch { throw new Error( 'Failed to deserialize transaction as either versioned or legacy format' ) } } return await baseAdapter.signTransaction(deserializedTx) }, // @ts-expect-error return type includes VersionedTransaction signAllSerializedTransactions: async ( transactions: ArrayBufferLike[] ) => { const deserializedTransactions: ( | Transaction | VersionedTransaction )[] = [] for (const transaction of transactions) { const uint8Array = new Uint8Array(transaction) let deserializedTx: Transaction | VersionedTransaction try { deserializedTx = VersionedTransaction.deserialize(uint8Array) } catch { try { deserializedTx = Transaction.from(uint8Array) } catch { throw new Error( 'Failed to deserialize one or more transactions' ) } } deserializedTransactions.push(deserializedTx) } return await baseAdapter.signAllTransactions( deserializedTransactions ) } } }) walletNames.add(baseAdapter.name) } } catch { // Silently handle if Base Wallet adapter fails to initialize } return solanaWallets } private isSolanaWallet(wallet: Wallet): boolean { const chains = wallet.chains || [] const hasSolanaChain = chains.some(chain => chain.startsWith('solana:')) return hasSolanaChain } private generateWalletId(wallet: Wallet): string { const chain = wallet.chains?.[0] || 'unknown' return `${wallet.name}-${chain}`.toLowerCase().replace(/\s+/g, '-') } /** * Checks if a value looks like a Tron provider (has request and tronWeb) */ private isTronProvider(value: unknown): boolean { if (!value || typeof value !== 'object') return false const provider = value as Record return provider['tronWeb'] != null && 'request' in provider } private getTronWallets(injectedIds: string[]): TronWalletInfo[] { if (typeof window === 'undefined' || injectedIds.length === 0) { return [] } const wallets: TronWalletInfo[] = [] const seen = new Set() for (const injectedId of injectedIds) { // Resolve nested window path (e.g. 'tokenpocket.tron' -> window.tokenpocket.tron) // eslint-disable-next-line @typescript-eslint/no-explicit-any const provider = injectedId.split('.').reduce( (acc, part) => acc?.[part], // eslint-disable-next-line @typescript-eslint/no-explicit-any window as any ) // Check if it looks like a TronProvider (has tronWeb.trx) if (this.isTronProvider(provider) && !seen.has(injectedId)) { seen.add(injectedId) const name = injectedId.split('.')[0] || injectedId const uuid = `tron-${injectedId}`.toLowerCase().replace(/\./g, '-') // Create proxied provider with all TronWeb methods // eslint-disable-next-line @typescript-eslint/no-explicit-any const actualProvider = provider as any const proxiedProvider = Comlink.proxy({ ready: actualProvider.ready ?? false, request: actualProvider.request ? async (params: { method: string; params?: unknown }) => await actualProvider.request(params) : undefined, tronWeb: Comlink.proxy({ getDefaultAddress: async () => ({ base58: actualProvider.tronWeb.defaultAddress?.base58 || false, hex: actualProvider.tronWeb.defaultAddress?.hex || false }), trx: Comlink.proxy({ sign: async (transaction: unknown) => await actualProvider.tronWeb.trx.sign(transaction), signMessageV2: async (message: string) => await actualProvider.tronWeb.trx.signMessageV2(message), sendRawTransaction: async (signedTransaction: unknown) => await actualProvider.tronWeb.trx.sendRawTransaction( signedTransaction ) }), transactionBuilder: Comlink.proxy({ sendTrx: async (to: string, amount: number, from: string) => await actualProvider.tronWeb.transactionBuilder.sendTrx( to, amount, from ), triggerSmartContract: async ( contractAddress: string, functionSelector: string, options: Record, // eslint-disable-next-line @typescript-eslint/no-explicit-any parameter: Array<{ type: string; value: any }>, issuerAddress: string ) => await actualProvider.tronWeb.transactionBuilder.triggerSmartContract( contractAddress, functionSelector, options, parameter, issuerAddress ) }), toHex: (message: string) => actualProvider.tronWeb.toHex(message) }) }) wallets.push({ uuid, name, injectedId, provider: proxiedProvider }) } } return wallets } private isTonConnectBridge(value: unknown): boolean { if (!value || typeof value !== 'object') return false const bridge = value as Record return ( typeof bridge['connect'] === 'function' && typeof bridge['send'] === 'function' && typeof bridge['listen'] === 'function' ) } private getTonWallets(jsBridgeKeys: string[]): TonWalletInfo[] { if (typeof window === 'undefined' || jsBridgeKeys.length === 0) { return [] } const wallets: TonWalletInfo[] = [] const seen = new Set() for (const key of jsBridgeKeys) { if (seen.has(key)) continue // eslint-disable-next-line @typescript-eslint/no-explicit-any const root = (window as any)[key] if (!root || typeof root !== 'object') continue const bridge = root['tonconnect'] if (!this.isTonConnectBridge(bridge)) continue seen.add(key) // Explicitly whitelist bridge methods to bound cross-origin surface // eslint-disable-next-line @typescript-eslint/no-explicit-any const actualBridge = bridge as any wallets.push({ uuid: `ton-${key}`.toLowerCase(), name: actualBridge.deviceInfo?.appName || key, icon: '', jsBridgeKey: key, provider: Comlink.proxy({ deviceInfo: actualBridge.deviceInfo, walletInfo: actualBridge.walletInfo, protocolVersion: actualBridge.protocolVersion ?? 2, isWalletBrowser: actualBridge.isWalletBrowser ?? false, connect: async (protocolVersion: number, message: string) => await actualBridge.connect(protocolVersion, message), restoreConnection: async () => await actualBridge.restoreConnection(), send: async (message: string) => await actualBridge.send(message), listen: (callback: (event: string) => void) => { const unsub = actualBridge.listen(callback) return Comlink.proxy(unsub) }, disconnect: actualBridge.disconnect ? async () => await actualBridge.disconnect() : undefined }) }) } return wallets } public destroy(): void { this.destroyed = true // Detach the 'message' listener(s) registered by expose(). Comlink only // removes them on a child RELEASE, so without this a closed session leaves a // stale listener that wins the next session's discovery reads (registration // order) with empty/ready values, making injected wallets appear absent. if (this.endpoint) { for (const { listener, options } of this.exposeListeners) { // Mirror the original addEventListener call (including `options`) — // DOM removal matching is identity-based: a capture-phase listener // only detaches when the same `capture` flag is passed back. this.endpoint.removeEventListener('message', listener, options) } } this.exposeListeners = [] if (this.parentAPI) { this.parentAPI.eip6963WalletsReady = false this.parentAPI.walletStandardWalletsReady = false this.parentAPI.tronWalletsReady = false this.parentAPI.tonWalletsReady = false this.parentAPI.eip6963Wallets = [] this.parentAPI.walletStandardWallets = [] this.parentAPI.tronWallets = [] this.parentAPI.tonWallets = [] this.parentAPI = null } this.endpoint = null this.iframe = null } }