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 } /** * Minimal structural shape of a wallet-standard wallet's `standard:events` * change stream — just enough for `subscribeSolanaWalletEvent` to observe live * account switches without pulling in the full `@wallet-standard/features` * types. `on('change', …)` returns an unsubscribe function (wallet-standard * contract); `accounts` is the wallet's currently-authorized account set, whose * first entry is the active account. */ /** * A wallet-standard `change` event stream. Aliased because bip122 wallets * publish it under two different feature keys and the shape is identical for * both — writing the signature out twice invites them drifting apart. */ interface Bip122ChangeEvents { on?: ( event: 'change', listener: (properties: { accounts?: readonly unknown[] }) => void ) => () => void } interface StandardEventsCapableWallet { accounts?: readonly { address: string }[] features?: { 'standard:events'?: Bip122ChangeEvents // MetaMask publishes its bitcoin account-change stream under // `bitcoin:events`, NOT `standard:events` (Trust uses the latter). Same // `on('change', …)` contract, different key — so both are read. Mirrors // `BitcoinWalletService`'s identically-named type. 'bitcoin:events'?: Bip122ChangeEvents } } export interface WalletStandardInfo { uuid: string name: string chains: string[] features: string[] adapter: ExtendedStandardWalletAdapter | undefined } interface Bip122ConnectFeature { connect: (input: unknown) => Promise } /** * Marshaled so a FRAMED child can revoke the origin's bip122 authorization. * Without it the child's only route was a local-cache clear, which leaves the * grant intact — so a re-connect replays the same account and a pinned-address * connect can never reach a different one. */ interface Bip122DisconnectFeature { disconnect: () => Promise } /** * A bip122 account reduced to plain, structured-cloneable data. * * `publicKey` travels as a number array rather than a `Uint8Array`: the child * rebuilds it (`BridgeChild.reviveBip122Account`), which keeps the wire shape * free of typed-array assumptions. */ export interface Bip122WireAccount { address: string publicKey: number[] purpose?: string } /** * Reshape a wallet-native bip122 account into plain data before it crosses * Comlink. * * REQUIRED, not cosmetic. Comlink marshals by structured clone, which copies * only OWN ENUMERABLE properties — and MetaMask's account is a class instance * whose `address`/`publicKey` are accessors on the prototype chain * (`Object.getOwnPropertyNames(account)` is `[]`). Passing one through * unchanged therefore delivers `{}` to the child, and the first * `bytesToHex(account.publicKey)` throws "bytes is not iterable" — which made * every framed bip122 connect fail before reaching the wallet. */ export function toBip122WireAccount(account: unknown): Bip122WireAccount { const a = account as { address: string publicKey: Uint8Array | number[] purpose?: string } return { address: a.address, // `Array.from` handles both a real Uint8Array and an already-plain array, // so a wallet that hands back either shape marshals identically. publicKey: Array.from(a.publicKey ?? []), ...(a.purpose ? { purpose: a.purpose } : {}) } } interface Bip122SignMessageFeature { signMessage: (input: unknown) => Promise } interface Bip122SignTransactionFeature { signTransaction: (input: unknown) => Promise } /** * Wallet Standard `bitcoin:*` wallet, reshaped into plain data (`name`, * `accounts`) plus Comlink-proxied feature methods so it survives the * postMessage bridge — mirrors `WalletStandardBitcoinWallet` * (`@meshconnect/uwc-bitcoin`), duck-typed here rather than imported to avoid * a new cross-package dependency (same approach as the Tron/TON provider * shapes below). */ export interface Bip122WireWallet { name: string /** Plain data — see {@link toBip122WireAccount} for why this cannot be the * wallet's own account objects. */ accounts: Bip122WireAccount[] features: { 'bitcoin:connect'?: Bip122ConnectFeature | undefined 'bitcoin:disconnect'?: Bip122DisconnectFeature | undefined 'bitcoin:signMessage'?: Bip122SignMessageFeature | undefined 'bitcoin:signTransaction'?: Bip122SignTransactionFeature | undefined } } export interface Bip122WalletInfo { uuid: string name: string features: string[] wallet: Bip122WireWallet } 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 bip122Wallets: Bip122WalletInfo[] bip122WalletsReady: boolean tronWallets: TronWalletInfo[] tronWalletsReady: boolean tonWallets: TonWalletInfo[] tonWalletsReady: boolean parentOrigin: string discoverTronWallets: (injectedIds: string[]) => void discoverTonWallets: (jsBridgeKeys: string[]) => void /** * Attach a real listener for `eventName` on the EIP-6963 provider. Awaits the * one-shot EIP-6963 discovery window before looking up the wallet by `uuid` in * `eip6963Wallets`; if that misses and an optional `rdns` was supplied, falls * back to matching by `rdns` (a wallet-defined identifier stable across * injection contexts). Forwards every event firing to `callback` (a * Comlink-proxied callback the child supplies). Resolves to a teardown that * removes the listener. THROWS when neither `uuid` nor the `rdns` fallback * resolves a wallet (surfaced to the child as a bridge error rather than a * silent no-op); resolves to `undefined` only when the wallet IS found but * exposes no `.on` to observe. */ subscribeEip6963ProviderEvent: ( uuid: string, eventName: 'accountsChanged' | 'chainChanged' | 'disconnect', callback: (payload?: unknown) => void, rdns?: string ) => Promise<(() => void) | undefined> /** * Same as `subscribeEip6963ProviderEvent`, but for an injected Tron * provider resolved fresh by its window path (`injectedId`) rather than a * cached discovery list, since Tron providers aren't cached the same way. */ subscribeTronProviderEvent: ( injectedId: string, eventName: 'accountsChanged' | 'disconnect', callback: (payload?: unknown) => void ) => (() => void) | undefined /** * Relay a connected Solana wallet's account-change events across the * bridge. `walletId` is the SAME id `walletStandardWallets[].uuid` * publishes (see `generateWalletId` — name+chain based, not a real UUID), * which the child mirrors back as * `SolanaWalletService.connectedWalletUuid`. Deliberately re-resolves the * REAL, in-frame adapter (via `getWallets()`) rather than reusing the * serialized `walletStandardWallets[].adapter` object — that object only * exposes the whitelisted sign/transaction methods for Comlink, not * `.on`/`.off`, so it can't be used to attach a live listener. Forwards a * plain `string | null` (never a `PublicKey`) to `callback`, a * Comlink-proxied callback the child supplies. Throws when no wallet * matches `walletId` (reported as a bridge error on the child side, * mirroring `subscribeEip6963ProviderEvent`'s uuid-miss); returns * `undefined` when the resolved adapter can't be observed. */ subscribeSolanaWalletEvent: ( walletId: string, callback: (address: string | null) => void ) => (() => void) | undefined /** * Bitcoin peer of `subscribeSolanaWalletEvent`. `walletId` is the SAME id * `bip122Wallets[].uuid` publishes (see `generateBip122WalletId`), which the * child mirrors back as `BitcoinWalletService.connectedWalletUuid`. Bitcoin * has no adapter class re-emitting `connect`/`disconnect` on a switch (the * way `StandardWalletAdapter` does for Solana) — `standard:events` is the * ONLY path, not a preferred one with a fallback. Forwards a plain * `string | null` (never the raw account object) to `callback`, a * Comlink-proxied callback the child supplies. Throws when no wallet * matches `walletId` (reported as a bridge error on the child side, * mirroring `subscribeSolanaWalletEvent`'s walletId-miss); returns * `undefined` when the resolved wallet exposes no `standard:events`. */ subscribeBip122WalletEvent: ( walletId: string, callback: (address: string | null) => void ) => (() => void) | undefined } 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 // Captured so subscribeEip6963ProviderEvent can await the one-shot discovery // window before its uuid/rdns lookup, instead of racing an empty // eip6963Wallets when a subscribe call arrives before discovery resolves. private eip6963DiscoveryPromise: Promise | null = null 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() this.eip6963DiscoveryPromise = eip6963Promise // Solana and Bitcoin are both synchronous Wallet Standard registry reads, // so run them inline (no benefit to a microtask hop). const walletStandardWallets = this.getSolanaWallets() const bip122Wallets = this.getBitcoinWallets() this.parentAPI = { eip6963Wallets: [], eip6963WalletsReady: false, walletStandardWallets: walletStandardWallets, walletStandardWalletsReady: true, bip122Wallets: bip122Wallets, bip122WalletsReady: 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 } }, subscribeEip6963ProviderEvent: async ( uuid: string, eventName: 'accountsChanged' | 'chainChanged' | 'disconnect', callback: (payload?: unknown) => void, rdns?: string ): Promise<(() => void) | undefined> => { // Discovery is one-shot with a fixed window (see // discovereip6963Wallets) — a subscribe call can legitimately arrive // before it resolves (e.g. immediately on page load during a session // restore). Awaiting here means the lookup below always runs against // the FINAL discovered list instead of racing a still-empty one. This // is safe to await unconditionally: it resolves immediately once // discovery has already completed. Comlink calls are already async // over postMessage (the child already wraps this in // `Promise.resolve()` — see BridgeChild.ts), so returning a Promise // here changes nothing from the child's perspective. await this.eip6963DiscoveryPromise const wallets = this.parentAPI?.eip6963Wallets ?? [] // uuid is the primary match. It can genuinely diverge from what this // parent discovered: each independent EIP-6963 injection context // (top frame vs. this iframe) mints its own random uuid per spec, so // the SAME physical wallet can be known under two different uuids — // widening the discovery window (see the sibling plan this one // follows) only narrows that race, it can't close it. rdns // ("io.metamask"-style) is wallet-defined and stable across // injection contexts, so it's a reliable fallback identity when the // uuid the child captured doesn't match anything this parent saw. const wallet = wallets.find(w => w.uuid === uuid) ?? (rdns ? wallets.find(w => w.rdns === rdns) : undefined) if (!wallet) { // Neither the uuid nor (if supplied) the rdns matched anything — // genuinely not discovered by this parent. Throwing — instead of // returning undefined like the "found but not observable" branch // below does — turns this into a reported bridge error on the // child side (see BridgeChild.ts's // window.__uwcSubscribeEip6963ProviderEvent) instead of a silent, // undetectable no-op. throw new Error( `subscribeEip6963ProviderEvent: no wallet found for uuid "${uuid}"` ) } // eslint-disable-next-line @typescript-eslint/no-explicit-any const provider = wallet.provider as any if (!provider || typeof provider.on !== 'function') return undefined // `callback` here IS the Comlink proxy the child passed — registering // it DIRECTLY as the provider's own event listener would make Comlink // serialize EVERY argument the wallet's own dispatch calls it with. // Some wallets' internal EventEmitter passes extra, non-EIP-1193 // arguments alongside the real payload (observed in production: a // wallet's own internal accountsChanged handler function), and // Comlink can't clone a function — throwing "DataCloneError" the // moment the wallet actually fires the event, silently breaking // delivery. A local (non-proxied, ordinary JS) wrapper that only // forwards the first argument sidesteps this entirely — extra // arguments are simply never looked at, the same way the direct // (non-bridge) `EthereumWalletService.onLifecycleEvent` path already // does by using a plain single-parameter handler. const localListener = (payload?: unknown) => callback(payload) provider.on(eventName, localListener) // The teardown crosses back to the child as a Comlink return value — // like TON's `listen()` above, an un-proxied function can't be // structured-cloned over postMessage (Comlink throws // "Unserializable return value" the moment the child tries to // receive it), silently losing the child's ability to unsubscribe. // The block body below is deliberate, not stylistic: an // expression-bodied `() => provider.removeListener?.(...)` would // implicitly return removeListener's OWN return value — by // EventEmitter convention that's `this` (the whole provider), which // is itself unserializable and reproduces the exact same failure one // level down, the moment this teardown is actually invoked. return Comlink.proxy(() => { provider.removeListener?.(eventName, localListener) }) }, subscribeTronProviderEvent: ( injectedId: string, eventName: 'accountsChanged' | 'disconnect', callback: (payload?: unknown) => void ): (() => void) | undefined => { const provider = this.resolveTronProviderByPath(injectedId) if (!provider) { // Same rationale as subscribeEip6963ProviderEvent above — surface // a genuine "nothing at this path" miss as a reported bridge // error instead of a silent no-op. throw new Error( `subscribeTronProviderEvent: no provider found at injectedId "${injectedId}"` ) } // Choose an EVENT-CAPABLE emitter. The provider resolved by // `injectedId` (e.g. `window.tronLink`) carries `request`+`tronWeb` — // enough for discovery + signing — but is NOT necessarily an event // emitter. TronLink specifically injects a SEPARATE TIP-1193 provider // at `window.tron` that is the one emitting `accountsChanged` / // `disconnect`; `window.tronLink` has no `.on`, so binding there // silently dropped every in-wallet account switch (MFS-778). Order: // 1) the resolved provider itself (Trust / Bitget expose their own `.on`) // 2) TronLink's TIP-1193 `window.tron` — but ONLY for the TronLink // subscription (see `resolveTronEventEmitter`). A wallet whose own // Tron provider lacks `.on` (e.g. OKX's `okxwallet.tronLink`) must // NOT inherit TronLink's `window.tron`, or its switches bind to the // wrong wallet and are dropped — it falls through to (3) instead. // 3) the window `message` channel (below), which OKX actually uses. const emitter = typeof provider.on === 'function' ? provider : this.resolveTronEventEmitter(injectedId, provider) if (emitter) { // Same "don't register the raw Comlink proxy directly" requirement // as subscribeEip6963ProviderEvent above. const localListener = (payload?: unknown) => callback(payload) emitter.on(eventName, localListener) // Same block-body requirement as subscribeEip6963ProviderEvent above // — discard removeListener's own (EventEmitter-convention `this`) // return value rather than implicitly returning it. return Comlink.proxy(() => { emitter.removeListener?.(eventName, localListener) }) } // 3) Legacy TronLink builds expose neither `.on` surface: the wallet // broadcasts account/disconnect changes as `window` `message` // events instead. Last-resort, and window-global (not // wallet-scoped), so only reached when no emitter exists at all. return Comlink.proxy( this.subscribeTronWindowMessage(eventName, callback) ) }, subscribeSolanaWalletEvent: ( walletId: string, callback: (address: string | null) => void ): (() => void) | undefined => { const adapter = this.resolveSolanaAdapterForWalletEvent(walletId) if (!adapter) { // Same rationale as subscribeEip6963ProviderEvent's uuid miss — // surface a genuine "nothing at this walletId" miss as a reported // bridge error instead of a silent no-op. throw new Error( `subscribeSolanaWalletEvent: no wallet found for walletId "${walletId}"` ) } // PREFERRED — subscribe to the underlying wallet's wallet-standard // `standard:events` `change` stream, which fires on EVERY account switch // and carries the wallet's updated `accounts`. We must NOT derive switches // from the adapter's own `connect`/`disconnect` here: // `resolveSolanaAdapterForWalletEvent` returns a FRESH, never-connected // `StandardWalletAdapter`, and that adapter only translates a `change` into // `connect`/`disconnect` when it is already connected — so on the bridge an // account-to-account switch emits NEITHER event and is silently dropped // (only an eventual full de-auth trips `disconnect`). That is the Phantom // multi-switch regression: several switches produce no telemetry and no // session update, and only the final de-auth disconnects (MFS-778). // Reading the wallet's `standard:events` directly captures every switch. const standardWallet = ( adapter as unknown as { wallet?: StandardEventsCapableWallet } ).wallet const standardEvents = standardWallet?.features?.['standard:events'] if (standardWallet && typeof standardEvents?.on === 'function') { // Forward the authoritative current active account as a plain base58 // string | null BEFORE crossing Comlink (the raw account object isn't // structured-cloneable, and the child only needs the address). null = // the switch de-authorized the dapp → the child maps it to [] (disconnect), // a non-null address maps to [address] (switch), exactly as the legacy // connect/disconnect path did. const forwardActiveAccount = () => { callback(standardWallet.accounts?.[0]?.address ?? null) } const off = standardEvents.on('change', properties => { // React only to account-set changes; ignore pure chain/feature deltas. if (properties && 'accounts' in properties) forwardActiveAccount() }) // Block body (not an expression arrow): the wallet-standard unsubscribe // returns its own value, which must not become this teardown's // (unserializable) return across Comlink. Best-effort + idempotent. return Comlink.proxy(() => { try { off?.() } catch { // Unsubscribe must never throw across the bridge. } }) } // FALLBACK — a traditional (non-wallet-standard) adapter, e.g. the legacy // Base Wallet, exposes only EventEmitter `connect`/`disconnect` and no // `standard:events` stream. Its connected instance re-emits `connect` on a // switch, so this path stays correct for it. if (typeof adapter.on !== 'function') return undefined // Reduce to a plain string | null BEFORE crossing Comlink — the raw // PublicKey (or the adapter/emitter itself) must never be forwarded: // it isn't structured-cloneable and the child only ever needs the // base58 address. const onConnect = (publicKey?: { toBase58?: () => string } | null) => { callback(publicKey?.toBase58?.() ?? null) } const onDisconnect = () => callback(null) adapter.on('connect', onConnect) adapter.on('disconnect', onDisconnect) // Block body — same requirement as subscribeEip6963ProviderEvent's // teardown above: an expression-bodied `() => adapter.off(...)` // would implicitly return `.off`'s own (EventEmitter-convention // `this`) value, reproducing the unserializable-return-value failure // one level down, the moment this teardown is actually invoked. return Comlink.proxy(() => { adapter.off('connect', onConnect) adapter.off('disconnect', onDisconnect) }) }, subscribeBip122WalletEvent: ( walletId: string, callback: (address: string | null) => void ): (() => void) | undefined => { const wallet = this.resolveBip122WalletForEvent(walletId) if (!wallet) { // Same rationale as subscribeSolanaWalletEvent's walletId miss — // surface a genuine "nothing at this walletId" miss as a reported // bridge error instead of a silent no-op. throw new Error( `subscribeBip122WalletEvent: no wallet found for walletId "${walletId}"` ) } // ONLY path — Bitcoin has no adapter class to re-emit connect/disconnect // on a switch the way StandardWalletAdapter does for Solana. // // Wallets disagree on WHERE that signal lives: Trust publishes // `standard:events`, MetaMask publishes `bitcoin:events`. Both expose // the same `on('change', …)` contract. Reading only the standard key // made every MetaMask account switch unobservable on the FRAMED path — // i.e. production Link, since the child routes through this bridge // before its own direct-mode fallback can run. Select on callability so // a present-but-malformed feature can't shadow a working one. const standardWallet = wallet as unknown as StandardEventsCapableWallet const features = standardWallet.features const standardEvents = typeof features?.['standard:events']?.on === 'function' ? features['standard:events'] : features?.['bitcoin:events'] if (typeof standardEvents?.on !== 'function') return undefined // Reduce to a plain string | null BEFORE crossing Comlink — the raw // account object isn't structured-cloneable and the child only needs // the address. null = the switch de-authorized the dapp → the child // maps it to [] (disconnect), a non-null address maps to [address]. const forwardActiveAccount = () => { callback(standardWallet.accounts?.[0]?.address ?? null) } const off = standardEvents.on('change', properties => { // React only to account-set changes; ignore pure chain/feature deltas. if (properties && 'accounts' in properties) forwardActiveAccount() }) // Block body (not an expression arrow): the wallet-standard unsubscribe // returns its own value, which must not become this teardown's // (unserializable) return across Comlink. Best-effort + idempotent. return Comlink.proxy(() => { try { off?.() } catch { // Unsubscribe must never throw across the bridge. } }) } } // 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[] = [] // 300ms (not 100ms): a wallet extension racing other extensions to // inject, or a slow page load, can announce after a 100ms window // closes — this parent's discovery is one-shot with no retry, so a // missed announcement is missed forever for this session. Widening // the window doesn't make discovery bulletproof (no fixed window // can be), but meaningfully reduces how often a real wallet gets // missed here and pushed onto the CHILD's local-discovery fallback, // which is where the uuid-mismatch bug this constant guards against // originates. const timeout = 300 // Wait 300ms 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 // @ts-expect-error 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'], 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 isBitcoinWallet(wallet: Wallet): boolean { const chains = wallet.chains || [] return chains.some(chain => chain.startsWith('bitcoin:')) } /** * Wallet Standard `bitcoin:*` discovery — the Bitcoin peer of * `getSolanaWallets`. Reshapes each matching registry wallet into plain * data (`accounts`) plus individually Comlink-proxied feature methods * (`bitcoin:connect`/`signMessage`/`signTransaction`), each left `undefined` * when the real wallet doesn't declare it — so `WalletStandardBitcoinAdapter`'s * own presence checks (`if (!feature) throw ...`) still work once marshaled * across the bridge. */ private getBitcoinWallets(): Bip122WalletInfo[] { const { get } = getWallets() const wallets = get() const bitcoinWallets: Bip122WalletInfo[] = [] for (const wallet of wallets) { if (!this.isBitcoinWallet(wallet)) continue // eslint-disable-next-line @typescript-eslint/no-explicit-any const features = wallet.features as Record const connectFeature = features['bitcoin:connect'] const disconnectFeature = features['bitcoin:disconnect'] const signMessageFeature = features['bitcoin:signMessage'] const signTransactionFeature = features['bitcoin:signTransaction'] bitcoinWallets.push({ uuid: this.generateBip122WalletId(wallet), name: wallet.name, features: Object.keys(wallet.features), wallet: { name: wallet.name, accounts: (wallet.accounts ?? []).map(toBip122WireAccount), features: { 'bitcoin:connect': connectFeature ? Comlink.proxy({ // Reshape the RESULT too, not just the snapshot above — the // accounts a connect returns are the same prototype-accessor // instances and would otherwise cross as `{}`. connect: async (input: unknown) => { const result = (await connectFeature.connect(input)) as { accounts?: unknown[] } return { accounts: (result?.accounts ?? []).map( toBip122WireAccount ) } } }) : undefined, 'bitcoin:disconnect': disconnectFeature ? Comlink.proxy({ disconnect: async () => await disconnectFeature.disconnect() }) : undefined, 'bitcoin:signMessage': signMessageFeature ? Comlink.proxy({ signMessage: async (input: unknown) => await signMessageFeature.signMessage(input) }) : undefined, 'bitcoin:signTransaction': signTransactionFeature ? Comlink.proxy({ signTransaction: async (input: unknown) => await signTransactionFeature.signTransaction(input) }) : undefined } } }) } return bitcoinWallets } private generateWalletId(wallet: Wallet): string { const chain = wallet.chains?.[0] || 'unknown' return `${wallet.name}-${chain}`.toLowerCase().replace(/\s+/g, '-') } /** * Bitcoin peer of `generateWalletId`, kept separate rather than shared: it * keys on the wallet's first `bitcoin:` chain, not `chains[0]` — MetaMask's * bitcoin:* wallet is multi-chain (eip155 chains registered first), so * `chains[0]` would key this id off an unrelated namespace. Mirrors the * same fix already applied to the local (unframed) discovery path in * wallet-standard-discovery.ts. Used by both `getBitcoinWallets()` and * `resolveBip122WalletForEvent()` so they agree on the same id. */ private generateBip122WalletId(wallet: Wallet): string { const bitcoinChain = wallet.chains?.find(chain => chain.startsWith('bitcoin:')) || wallet.chains?.[0] || 'unknown' return `${wallet.name}-${bitcoinChain}`.toLowerCase().replace(/\s+/g, '-') } /** * Resolve the REAL registry wallet for `walletId` (the same id * `getBitcoinWallets()` publishes as `Bip122WalletInfo.uuid`) so * `subscribeBip122WalletEvent` can attach a live listener. Unlike Solana, * Bitcoin has no adapter class to construct — `getBitcoinWallets()` already * reshapes the raw registry `Wallet` directly, so this returns that same * raw `Wallet`, which still carries `standard:events` (the reshaped * `Bip122WireWallet` sent across the bridge does not). */ private resolveBip122WalletForEvent(walletId: string): Wallet | undefined { const { get } = getWallets() const wallets = get() for (const wallet of wallets) { if (!this.isBitcoinWallet(wallet)) continue if (this.generateBip122WalletId(wallet) !== walletId) continue return wallet } return undefined } /** * 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 } /** * Resolve the REAL (non-serialized) Solana adapter for `walletId` — the * same id `getSolanaWallets()` publishes as `WalletStandardInfo.uuid` (see * `generateWalletId`). Mirrors `getSolanaWallets()`'s wallet-standard + * traditional-Base-Wallet resolution, but returns the adapter instance * itself instead of the Comlink-serialized object literal, since only the * real instance has `.on`/`.off` to subscribe to. */ private resolveSolanaAdapterForWalletEvent( walletId: string ): StandardWalletAdapter | BaseWalletAdapter | undefined { const { get } = getWallets() const wallets = get() for (const wallet of wallets) { if (!this.isSolanaWallet(wallet)) continue if (this.generateWalletId(wallet) !== walletId) continue if (!isWalletAdapterCompatibleStandardWallet(wallet)) return undefined return new StandardWalletAdapter({ wallet }) } try { const baseAdapter = new BaseWalletAdapter() const baseWalletId = `${baseAdapter.name}-traditional` .toLowerCase() .replace(/\s+/g, '-') const isDetected = baseAdapter.readyState === WalletReadyState.Installed || baseAdapter.readyState === WalletReadyState.Loadable if (baseWalletId === walletId && isDetected) { return baseAdapter } } catch { // Base Wallet adapter failed to initialize — no traditional fallback. } return undefined } /** Resolve a nested window path (e.g. 'tokenpocket.tron' -> window.tokenpocket.tron). */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private resolveTronProviderByPath(injectedId: string): any { // eslint-disable-next-line @typescript-eslint/no-explicit-any return injectedId.split('.').reduce( (acc, part) => acc?.[part], // eslint-disable-next-line @typescript-eslint/no-explicit-any window as any ) } /** * TronLink's account/disconnect events fire on its TIP-1193 provider * (`window.tron`), NOT on the `window.tronLink` object the catalog * `injectedId` resolves to (that one carries `request`+`tronWeb` but is not * an event emitter). Returns TronLink's `window.tron` emitter, but ONLY when * the subscription is for TronLink itself. * * `window.tron` belongs exclusively to TronLink. Other Tron wallets whose own * provider happens to lack `.on` (confirmed for OKX's `okxwallet.tronLink`, * which is not an event emitter and broadcasts account switches on the window * `message` channel instead) MUST NOT inherit it — binding OKX's * `accountsChanged` on TronLink's `window.tron` means OKX's switches fire on * the wrong wallet's provider and are silently dropped. Gated on the * subscription's identity so only the TronLink path uses this fallback; every * other wallet falls through to `subscribeTronWindowMessage`. */ private resolveTronEventEmitter( injectedId: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any provider: any ): | { on: (event: string, handler: (...args: unknown[]) => void) => void removeListener?: ( event: string, handler: (...args: unknown[]) => void ) => void } | undefined { if (typeof window === 'undefined') return undefined // eslint-disable-next-line @typescript-eslint/no-explicit-any const win = window as any const tip1193 = win.tron if ( !tip1193 || typeof tip1193.on !== 'function' || !this.isTronProvider(tip1193) ) { return undefined } // Only TronLink may use its own `window.tron`. Identify the TronLink // subscription by provider identity (the resolved provider IS `window.tron` // / `window.tronLink`) or the canonical TronLink injectedId paths. const isTronLinkSubscription = provider === tip1193 || provider === win.tronLink || injectedId === 'tron' || injectedId === 'tronLink' if (isTronLinkSubscription) { return tip1193 } return undefined } /** * Last-resort account/disconnect subscription for TronLink builds that expose * neither an EIP-1193 nor a TIP-1193 `.on`: the wallet broadcasts changes as * `window` `message` events (`{ message: { action, data } }`). Maps the * TronLink `action` to the requested UWC event and forwards a * Comlink-cloneable payload (never the raw event). Returns a teardown. * * `message` is window-global (not wallet-scoped), so this is only reached * when no `.on` emitter exists anywhere for this session. */ private subscribeTronWindowMessage( eventName: 'accountsChanged' | 'disconnect', callback: (payload?: unknown) => void ): () => void { const handler = (event: MessageEvent) => { // Runs in the client's TOP frame and the payload flows straight into the // live session address, so only trust broadcasts from THIS window — the // page context TronLink/OKX inject into. Without this, any same-origin // sibling frame or `window.opener` could post `{ message: { action: // 'setAccount' } }` and move the user's account. Mirrors the posture of // `wrapWithSourceGuard` and `Comlink.expose(..., [iframeOrigin])`. if (event.source !== window || event.origin !== window.location.origin) { return } const message = ( event?.data as { message?: { action?: unknown; data?: unknown } } )?.message const action = message?.action if (typeof action !== 'string') return if ( eventName === 'accountsChanged' && (action === 'setAccount' || action === 'accountsChanged') ) { const data = message?.data as | { address?: string } | string | string[] | undefined const address = typeof data === 'string' ? data : Array.isArray(data) ? data[0] : data?.address // Match the TIP-1193 `accountsChanged` shape (`string[]`) so the // connector's normalization treats an absent address as a logout. callback(address ? [address] : []) } else if (eventName === 'disconnect' && action === 'disconnect') { callback() } } window.addEventListener('message', handler) return () => window.removeEventListener('message', handler) } 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) { const provider = this.resolveTronProviderByPath(injectedId) // 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.bip122WalletsReady = false this.parentAPI.tronWalletsReady = false this.parentAPI.tonWalletsReady = false this.parentAPI.eip6963Wallets = [] this.parentAPI.walletStandardWallets = [] this.parentAPI.bip122Wallets = [] this.parentAPI.tronWallets = [] this.parentAPI.tonWallets = [] this.parentAPI = null } this.endpoint = null this.iframe = null } }