@meshconnect/uwc-bridge-parent 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,6 +72,26 @@ interface ExtendedStandardWalletAdapter extends StandardWalletAdapter {
72
72
  ) => Promise<Uint8Array>
73
73
  }
74
74
 
75
+ /**
76
+ * Minimal structural shape of a wallet-standard wallet's `standard:events`
77
+ * change stream — just enough for `subscribeSolanaWalletEvent` to observe live
78
+ * account switches without pulling in the full `@wallet-standard/features`
79
+ * types. `on('change', …)` returns an unsubscribe function (wallet-standard
80
+ * contract); `accounts` is the wallet's currently-authorized account set, whose
81
+ * first entry is the active account.
82
+ */
83
+ interface StandardEventsCapableWallet {
84
+ accounts?: readonly { address: string }[]
85
+ features?: {
86
+ 'standard:events'?: {
87
+ on?: (
88
+ event: 'change',
89
+ listener: (properties: { accounts?: readonly unknown[] }) => void
90
+ ) => () => void
91
+ }
92
+ }
93
+ }
94
+
75
95
  export interface WalletStandardInfo {
76
96
  uuid: string
77
97
  name: string
@@ -139,6 +159,57 @@ export interface ParentAPI {
139
159
 
140
160
  discoverTronWallets: (injectedIds: string[]) => void
141
161
  discoverTonWallets: (jsBridgeKeys: string[]) => void
162
+
163
+ /**
164
+ * Attach a real listener for `eventName` on the EIP-6963 provider. Awaits the
165
+ * one-shot EIP-6963 discovery window before looking up the wallet by `uuid` in
166
+ * `eip6963Wallets`; if that misses and an optional `rdns` was supplied, falls
167
+ * back to matching by `rdns` (a wallet-defined identifier stable across
168
+ * injection contexts). Forwards every event firing to `callback` (a
169
+ * Comlink-proxied callback the child supplies). Resolves to a teardown that
170
+ * removes the listener. THROWS when neither `uuid` nor the `rdns` fallback
171
+ * resolves a wallet (surfaced to the child as a bridge error rather than a
172
+ * silent no-op); resolves to `undefined` only when the wallet IS found but
173
+ * exposes no `.on` to observe.
174
+ */
175
+ subscribeEip6963ProviderEvent: (
176
+ uuid: string,
177
+ eventName: 'accountsChanged' | 'chainChanged' | 'disconnect',
178
+ callback: (payload?: unknown) => void,
179
+ rdns?: string
180
+ ) => Promise<(() => void) | undefined>
181
+
182
+ /**
183
+ * Same as `subscribeEip6963ProviderEvent`, but for an injected Tron
184
+ * provider resolved fresh by its window path (`injectedId`) rather than a
185
+ * cached discovery list, since Tron providers aren't cached the same way.
186
+ */
187
+ subscribeTronProviderEvent: (
188
+ injectedId: string,
189
+ eventName: 'accountsChanged' | 'disconnect',
190
+ callback: (payload?: unknown) => void
191
+ ) => (() => void) | undefined
192
+
193
+ /**
194
+ * Relay a connected Solana wallet's account-change events across the
195
+ * bridge. `walletId` is the SAME id `walletStandardWallets[].uuid`
196
+ * publishes (see `generateWalletId` — name+chain based, not a real UUID),
197
+ * which the child mirrors back as
198
+ * `SolanaWalletService.connectedWalletUuid`. Deliberately re-resolves the
199
+ * REAL, in-frame adapter (via `getWallets()`) rather than reusing the
200
+ * serialized `walletStandardWallets[].adapter` object — that object only
201
+ * exposes the whitelisted sign/transaction methods for Comlink, not
202
+ * `.on`/`.off`, so it can't be used to attach a live listener. Forwards a
203
+ * plain `string | null` (never a `PublicKey`) to `callback`, a
204
+ * Comlink-proxied callback the child supplies. Throws when no wallet
205
+ * matches `walletId` (reported as a bridge error on the child side,
206
+ * mirroring `subscribeEip6963ProviderEvent`'s uuid-miss); returns
207
+ * `undefined` when the resolved adapter can't be observed.
208
+ */
209
+ subscribeSolanaWalletEvent: (
210
+ walletId: string,
211
+ callback: (address: string | null) => void
212
+ ) => (() => void) | undefined
142
213
  }
143
214
 
144
215
  export class BridgeParent {
@@ -154,6 +225,10 @@ export class BridgeParent {
154
225
  options: EndpointAddEventListenerOptions
155
226
  }> = []
156
227
  private destroyed = false
228
+ // Captured so subscribeEip6963ProviderEvent can await the one-shot discovery
229
+ // window before its uuid/rdns lookup, instead of racing an empty
230
+ // eip6963Wallets when a subscribe call arrives before discovery resolves.
231
+ private eip6963DiscoveryPromise: Promise<DetectedWallet[]> | null = null
157
232
 
158
233
  constructor(iframe: HTMLIFrameElement) {
159
234
  if (!iframe) {
@@ -176,6 +251,7 @@ export class BridgeParent {
176
251
  // while that's in flight — those paths are independent. The readiness
177
252
  // flags gate the child from reading a still-empty array.
178
253
  const eip6963Promise = this.discovereip6963Wallets()
254
+ this.eip6963DiscoveryPromise = eip6963Promise
179
255
  // Solana is synchronous, so run it inline (no benefit to a microtask hop)
180
256
  const walletStandardWallets = this.getSolanaWallets()
181
257
 
@@ -206,6 +282,215 @@ export class BridgeParent {
206
282
  this.parentAPI.tonWallets = this.getTonWallets(jsBridgeKeys)
207
283
  this.parentAPI.tonWalletsReady = true
208
284
  }
285
+ },
286
+
287
+ subscribeEip6963ProviderEvent: async (
288
+ uuid: string,
289
+ eventName: 'accountsChanged' | 'chainChanged' | 'disconnect',
290
+ callback: (payload?: unknown) => void,
291
+ rdns?: string
292
+ ): Promise<(() => void) | undefined> => {
293
+ // Discovery is one-shot with a fixed window (see
294
+ // discovereip6963Wallets) — a subscribe call can legitimately arrive
295
+ // before it resolves (e.g. immediately on page load during a session
296
+ // restore). Awaiting here means the lookup below always runs against
297
+ // the FINAL discovered list instead of racing a still-empty one. This
298
+ // is safe to await unconditionally: it resolves immediately once
299
+ // discovery has already completed. Comlink calls are already async
300
+ // over postMessage (the child already wraps this in
301
+ // `Promise.resolve()` — see BridgeChild.ts), so returning a Promise
302
+ // here changes nothing from the child's perspective.
303
+ await this.eip6963DiscoveryPromise
304
+ const wallets = this.parentAPI?.eip6963Wallets ?? []
305
+ // uuid is the primary match. It can genuinely diverge from what this
306
+ // parent discovered: each independent EIP-6963 injection context
307
+ // (top frame vs. this iframe) mints its own random uuid per spec, so
308
+ // the SAME physical wallet can be known under two different uuids —
309
+ // widening the discovery window (see the sibling plan this one
310
+ // follows) only narrows that race, it can't close it. rdns
311
+ // ("io.metamask"-style) is wallet-defined and stable across
312
+ // injection contexts, so it's a reliable fallback identity when the
313
+ // uuid the child captured doesn't match anything this parent saw.
314
+ const wallet =
315
+ wallets.find(w => w.uuid === uuid) ??
316
+ (rdns ? wallets.find(w => w.rdns === rdns) : undefined)
317
+ if (!wallet) {
318
+ // Neither the uuid nor (if supplied) the rdns matched anything —
319
+ // genuinely not discovered by this parent. Throwing — instead of
320
+ // returning undefined like the "found but not observable" branch
321
+ // below does — turns this into a reported bridge error on the
322
+ // child side (see BridgeChild.ts's
323
+ // window.__uwcSubscribeEip6963ProviderEvent) instead of a silent,
324
+ // undetectable no-op.
325
+ throw new Error(
326
+ `subscribeEip6963ProviderEvent: no wallet found for uuid "${uuid}"`
327
+ )
328
+ }
329
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
330
+ const provider = wallet.provider as any
331
+ if (!provider || typeof provider.on !== 'function') return undefined
332
+ // `callback` here IS the Comlink proxy the child passed — registering
333
+ // it DIRECTLY as the provider's own event listener would make Comlink
334
+ // serialize EVERY argument the wallet's own dispatch calls it with.
335
+ // Some wallets' internal EventEmitter passes extra, non-EIP-1193
336
+ // arguments alongside the real payload (observed in production: a
337
+ // wallet's own internal accountsChanged handler function), and
338
+ // Comlink can't clone a function — throwing "DataCloneError" the
339
+ // moment the wallet actually fires the event, silently breaking
340
+ // delivery. A local (non-proxied, ordinary JS) wrapper that only
341
+ // forwards the first argument sidesteps this entirely — extra
342
+ // arguments are simply never looked at, the same way the direct
343
+ // (non-bridge) `EthereumWalletService.onLifecycleEvent` path already
344
+ // does by using a plain single-parameter handler.
345
+ const localListener = (payload?: unknown) => callback(payload)
346
+ provider.on(eventName, localListener)
347
+ // The teardown crosses back to the child as a Comlink return value —
348
+ // like TON's `listen()` above, an un-proxied function can't be
349
+ // structured-cloned over postMessage (Comlink throws
350
+ // "Unserializable return value" the moment the child tries to
351
+ // receive it), silently losing the child's ability to unsubscribe.
352
+ // The block body below is deliberate, not stylistic: an
353
+ // expression-bodied `() => provider.removeListener?.(...)` would
354
+ // implicitly return removeListener's OWN return value — by
355
+ // EventEmitter convention that's `this` (the whole provider), which
356
+ // is itself unserializable and reproduces the exact same failure one
357
+ // level down, the moment this teardown is actually invoked.
358
+ return Comlink.proxy(() => {
359
+ provider.removeListener?.(eventName, localListener)
360
+ })
361
+ },
362
+ subscribeTronProviderEvent: (
363
+ injectedId: string,
364
+ eventName: 'accountsChanged' | 'disconnect',
365
+ callback: (payload?: unknown) => void
366
+ ): (() => void) | undefined => {
367
+ const provider = this.resolveTronProviderByPath(injectedId)
368
+ if (!provider) {
369
+ // Same rationale as subscribeEip6963ProviderEvent above — surface
370
+ // a genuine "nothing at this path" miss as a reported bridge
371
+ // error instead of a silent no-op.
372
+ throw new Error(
373
+ `subscribeTronProviderEvent: no provider found at injectedId "${injectedId}"`
374
+ )
375
+ }
376
+ // Choose an EVENT-CAPABLE emitter. The provider resolved by
377
+ // `injectedId` (e.g. `window.tronLink`) carries `request`+`tronWeb` —
378
+ // enough for discovery + signing — but is NOT necessarily an event
379
+ // emitter. TronLink specifically injects a SEPARATE TIP-1193 provider
380
+ // at `window.tron` that is the one emitting `accountsChanged` /
381
+ // `disconnect`; `window.tronLink` has no `.on`, so binding there
382
+ // silently dropped every in-wallet account switch (MFS-778). Order:
383
+ // 1) the resolved provider itself (Trust / Bitget expose their own `.on`)
384
+ // 2) TronLink's TIP-1193 `window.tron` — but ONLY for the TronLink
385
+ // subscription (see `resolveTronEventEmitter`). A wallet whose own
386
+ // Tron provider lacks `.on` (e.g. OKX's `okxwallet.tronLink`) must
387
+ // NOT inherit TronLink's `window.tron`, or its switches bind to the
388
+ // wrong wallet and are dropped — it falls through to (3) instead.
389
+ // 3) the window `message` channel (below), which OKX actually uses.
390
+ const emitter =
391
+ typeof provider.on === 'function'
392
+ ? provider
393
+ : this.resolveTronEventEmitter(injectedId, provider)
394
+ if (emitter) {
395
+ // Same "don't register the raw Comlink proxy directly" requirement
396
+ // as subscribeEip6963ProviderEvent above.
397
+ const localListener = (payload?: unknown) => callback(payload)
398
+ emitter.on(eventName, localListener)
399
+ // Same block-body requirement as subscribeEip6963ProviderEvent above
400
+ // — discard removeListener's own (EventEmitter-convention `this`)
401
+ // return value rather than implicitly returning it.
402
+ return Comlink.proxy(() => {
403
+ emitter.removeListener?.(eventName, localListener)
404
+ })
405
+ }
406
+ // 3) Legacy TronLink builds expose neither `.on` surface: the wallet
407
+ // broadcasts account/disconnect changes as `window` `message`
408
+ // events instead. Last-resort, and window-global (not
409
+ // wallet-scoped), so only reached when no emitter exists at all.
410
+ return Comlink.proxy(
411
+ this.subscribeTronWindowMessage(eventName, callback)
412
+ )
413
+ },
414
+ subscribeSolanaWalletEvent: (
415
+ walletId: string,
416
+ callback: (address: string | null) => void
417
+ ): (() => void) | undefined => {
418
+ const adapter = this.resolveSolanaAdapterForWalletEvent(walletId)
419
+ if (!adapter) {
420
+ // Same rationale as subscribeEip6963ProviderEvent's uuid miss —
421
+ // surface a genuine "nothing at this walletId" miss as a reported
422
+ // bridge error instead of a silent no-op.
423
+ throw new Error(
424
+ `subscribeSolanaWalletEvent: no wallet found for walletId "${walletId}"`
425
+ )
426
+ }
427
+ // PREFERRED — subscribe to the underlying wallet's wallet-standard
428
+ // `standard:events` `change` stream, which fires on EVERY account switch
429
+ // and carries the wallet's updated `accounts`. We must NOT derive switches
430
+ // from the adapter's own `connect`/`disconnect` here:
431
+ // `resolveSolanaAdapterForWalletEvent` returns a FRESH, never-connected
432
+ // `StandardWalletAdapter`, and that adapter only translates a `change` into
433
+ // `connect`/`disconnect` when it is already connected — so on the bridge an
434
+ // account-to-account switch emits NEITHER event and is silently dropped
435
+ // (only an eventual full de-auth trips `disconnect`). That is the Phantom
436
+ // multi-switch regression: several switches produce no telemetry and no
437
+ // session update, and only the final de-auth disconnects (MFS-778).
438
+ // Reading the wallet's `standard:events` directly captures every switch.
439
+ const standardWallet = (
440
+ adapter as unknown as { wallet?: StandardEventsCapableWallet }
441
+ ).wallet
442
+ const standardEvents = standardWallet?.features?.['standard:events']
443
+ if (standardWallet && typeof standardEvents?.on === 'function') {
444
+ // Forward the authoritative current active account as a plain base58
445
+ // string | null BEFORE crossing Comlink (the raw account object isn't
446
+ // structured-cloneable, and the child only needs the address). null =
447
+ // the switch de-authorized the dapp → the child maps it to [] (disconnect),
448
+ // a non-null address maps to [address] (switch), exactly as the legacy
449
+ // connect/disconnect path did.
450
+ const forwardActiveAccount = () => {
451
+ callback(standardWallet.accounts?.[0]?.address ?? null)
452
+ }
453
+ const off = standardEvents.on('change', properties => {
454
+ // React only to account-set changes; ignore pure chain/feature deltas.
455
+ if (properties && 'accounts' in properties) forwardActiveAccount()
456
+ })
457
+ // Block body (not an expression arrow): the wallet-standard unsubscribe
458
+ // returns its own value, which must not become this teardown's
459
+ // (unserializable) return across Comlink. Best-effort + idempotent.
460
+ return Comlink.proxy(() => {
461
+ try {
462
+ off?.()
463
+ } catch {
464
+ // Unsubscribe must never throw across the bridge.
465
+ }
466
+ })
467
+ }
468
+
469
+ // FALLBACK — a traditional (non-wallet-standard) adapter, e.g. the legacy
470
+ // Base Wallet, exposes only EventEmitter `connect`/`disconnect` and no
471
+ // `standard:events` stream. Its connected instance re-emits `connect` on a
472
+ // switch, so this path stays correct for it.
473
+ if (typeof adapter.on !== 'function') return undefined
474
+
475
+ // Reduce to a plain string | null BEFORE crossing Comlink — the raw
476
+ // PublicKey (or the adapter/emitter itself) must never be forwarded:
477
+ // it isn't structured-cloneable and the child only ever needs the
478
+ // base58 address.
479
+ const onConnect = (publicKey?: { toBase58?: () => string } | null) => {
480
+ callback(publicKey?.toBase58?.() ?? null)
481
+ }
482
+ const onDisconnect = () => callback(null)
483
+ adapter.on('connect', onConnect)
484
+ adapter.on('disconnect', onDisconnect)
485
+ // Block body — same requirement as subscribeEip6963ProviderEvent's
486
+ // teardown above: an expression-bodied `() => adapter.off(...)`
487
+ // would implicitly return `.off`'s own (EventEmitter-convention
488
+ // `this`) value, reproducing the unserializable-return-value failure
489
+ // one level down, the moment this teardown is actually invoked.
490
+ return Comlink.proxy(() => {
491
+ adapter.off('connect', onConnect)
492
+ adapter.off('disconnect', onDisconnect)
493
+ })
209
494
  }
210
495
  }
211
496
 
@@ -288,7 +573,16 @@ export class BridgeParent {
288
573
  private async discovereip6963Wallets(): Promise<DetectedWallet[]> {
289
574
  return new Promise(resolve => {
290
575
  const detectedWallets: DetectedWallet[] = []
291
- const timeout = 100 // Wait 100ms for wallets to announce
576
+ // 300ms (not 100ms): a wallet extension racing other extensions to
577
+ // inject, or a slow page load, can announce after a 100ms window
578
+ // closes — this parent's discovery is one-shot with no retry, so a
579
+ // missed announcement is missed forever for this session. Widening
580
+ // the window doesn't make discovery bulletproof (no fixed window
581
+ // can be), but meaningfully reduces how often a real wallet gets
582
+ // missed here and pushed onto the CHILD's local-discovery fallback,
583
+ // which is where the uuid-mismatch bug this constant guards against
584
+ // originates.
585
+ const timeout = 300 // Wait 300ms for wallets to announce
292
586
 
293
587
  // Set up listener for wallet announcements
294
588
  const handleAnnouncement = (event: Event) => {
@@ -738,6 +1032,165 @@ export class BridgeParent {
738
1032
  return provider['tronWeb'] != null && 'request' in provider
739
1033
  }
740
1034
 
1035
+ /**
1036
+ * Resolve the REAL (non-serialized) Solana adapter for `walletId` — the
1037
+ * same id `getSolanaWallets()` publishes as `WalletStandardInfo.uuid` (see
1038
+ * `generateWalletId`). Mirrors `getSolanaWallets()`'s wallet-standard +
1039
+ * traditional-Base-Wallet resolution, but returns the adapter instance
1040
+ * itself instead of the Comlink-serialized object literal, since only the
1041
+ * real instance has `.on`/`.off` to subscribe to.
1042
+ */
1043
+ private resolveSolanaAdapterForWalletEvent(
1044
+ walletId: string
1045
+ ): StandardWalletAdapter | BaseWalletAdapter | undefined {
1046
+ const { get } = getWallets()
1047
+ const wallets = get()
1048
+
1049
+ for (const wallet of wallets) {
1050
+ if (!this.isSolanaWallet(wallet)) continue
1051
+ if (this.generateWalletId(wallet) !== walletId) continue
1052
+ if (!isWalletAdapterCompatibleStandardWallet(wallet)) return undefined
1053
+ return new StandardWalletAdapter({ wallet })
1054
+ }
1055
+
1056
+ try {
1057
+ const baseAdapter = new BaseWalletAdapter()
1058
+ const baseWalletId = `${baseAdapter.name}-traditional`
1059
+ .toLowerCase()
1060
+ .replace(/\s+/g, '-')
1061
+ const isDetected =
1062
+ baseAdapter.readyState === WalletReadyState.Installed ||
1063
+ baseAdapter.readyState === WalletReadyState.Loadable
1064
+ if (baseWalletId === walletId && isDetected) {
1065
+ return baseAdapter
1066
+ }
1067
+ } catch {
1068
+ // Base Wallet adapter failed to initialize — no traditional fallback.
1069
+ }
1070
+
1071
+ return undefined
1072
+ }
1073
+
1074
+ /** Resolve a nested window path (e.g. 'tokenpocket.tron' -> window.tokenpocket.tron). */
1075
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1076
+ private resolveTronProviderByPath(injectedId: string): any {
1077
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1078
+ return injectedId.split('.').reduce<any>(
1079
+ (acc, part) => acc?.[part],
1080
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1081
+ window as any
1082
+ )
1083
+ }
1084
+
1085
+ /**
1086
+ * TronLink's account/disconnect events fire on its TIP-1193 provider
1087
+ * (`window.tron`), NOT on the `window.tronLink` object the catalog
1088
+ * `injectedId` resolves to (that one carries `request`+`tronWeb` but is not
1089
+ * an event emitter). Returns TronLink's `window.tron` emitter, but ONLY when
1090
+ * the subscription is for TronLink itself.
1091
+ *
1092
+ * `window.tron` belongs exclusively to TronLink. Other Tron wallets whose own
1093
+ * provider happens to lack `.on` (confirmed for OKX's `okxwallet.tronLink`,
1094
+ * which is not an event emitter and broadcasts account switches on the window
1095
+ * `message` channel instead) MUST NOT inherit it — binding OKX's
1096
+ * `accountsChanged` on TronLink's `window.tron` means OKX's switches fire on
1097
+ * the wrong wallet's provider and are silently dropped. Gated on the
1098
+ * subscription's identity so only the TronLink path uses this fallback; every
1099
+ * other wallet falls through to `subscribeTronWindowMessage`.
1100
+ */
1101
+ private resolveTronEventEmitter(
1102
+ injectedId: string,
1103
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1104
+ provider: any
1105
+ ):
1106
+ | {
1107
+ on: (event: string, handler: (...args: unknown[]) => void) => void
1108
+ removeListener?: (
1109
+ event: string,
1110
+ handler: (...args: unknown[]) => void
1111
+ ) => void
1112
+ }
1113
+ | undefined {
1114
+ if (typeof window === 'undefined') return undefined
1115
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1116
+ const win = window as any
1117
+ const tip1193 = win.tron
1118
+ if (
1119
+ !tip1193 ||
1120
+ typeof tip1193.on !== 'function' ||
1121
+ !this.isTronProvider(tip1193)
1122
+ ) {
1123
+ return undefined
1124
+ }
1125
+ // Only TronLink may use its own `window.tron`. Identify the TronLink
1126
+ // subscription by provider identity (the resolved provider IS `window.tron`
1127
+ // / `window.tronLink`) or the canonical TronLink injectedId paths.
1128
+ const isTronLinkSubscription =
1129
+ provider === tip1193 ||
1130
+ provider === win.tronLink ||
1131
+ injectedId === 'tron' ||
1132
+ injectedId === 'tronLink'
1133
+ if (isTronLinkSubscription) {
1134
+ return tip1193
1135
+ }
1136
+ return undefined
1137
+ }
1138
+
1139
+ /**
1140
+ * Last-resort account/disconnect subscription for TronLink builds that expose
1141
+ * neither an EIP-1193 nor a TIP-1193 `.on`: the wallet broadcasts changes as
1142
+ * `window` `message` events (`{ message: { action, data } }`). Maps the
1143
+ * TronLink `action` to the requested UWC event and forwards a
1144
+ * Comlink-cloneable payload (never the raw event). Returns a teardown.
1145
+ *
1146
+ * `message` is window-global (not wallet-scoped), so this is only reached
1147
+ * when no `.on` emitter exists anywhere for this session.
1148
+ */
1149
+ private subscribeTronWindowMessage(
1150
+ eventName: 'accountsChanged' | 'disconnect',
1151
+ callback: (payload?: unknown) => void
1152
+ ): () => void {
1153
+ const handler = (event: MessageEvent) => {
1154
+ // Runs in the client's TOP frame and the payload flows straight into the
1155
+ // live session address, so only trust broadcasts from THIS window — the
1156
+ // page context TronLink/OKX inject into. Without this, any same-origin
1157
+ // sibling frame or `window.opener` could post `{ message: { action:
1158
+ // 'setAccount' } }` and move the user's account. Mirrors the posture of
1159
+ // `wrapWithSourceGuard` and `Comlink.expose(..., [iframeOrigin])`.
1160
+ if (event.source !== window || event.origin !== window.location.origin) {
1161
+ return
1162
+ }
1163
+ const message = (
1164
+ event?.data as { message?: { action?: unknown; data?: unknown } }
1165
+ )?.message
1166
+ const action = message?.action
1167
+ if (typeof action !== 'string') return
1168
+ if (
1169
+ eventName === 'accountsChanged' &&
1170
+ (action === 'setAccount' || action === 'accountsChanged')
1171
+ ) {
1172
+ const data = message?.data as
1173
+ | { address?: string }
1174
+ | string
1175
+ | string[]
1176
+ | undefined
1177
+ const address =
1178
+ typeof data === 'string'
1179
+ ? data
1180
+ : Array.isArray(data)
1181
+ ? data[0]
1182
+ : data?.address
1183
+ // Match the TIP-1193 `accountsChanged` shape (`string[]`) so the
1184
+ // connector's normalization treats an absent address as a logout.
1185
+ callback(address ? [address] : [])
1186
+ } else if (eventName === 'disconnect' && action === 'disconnect') {
1187
+ callback()
1188
+ }
1189
+ }
1190
+ window.addEventListener('message', handler)
1191
+ return () => window.removeEventListener('message', handler)
1192
+ }
1193
+
741
1194
  private getTronWallets(injectedIds: string[]): TronWalletInfo[] {
742
1195
  if (typeof window === 'undefined' || injectedIds.length === 0) {
743
1196
  return []
@@ -747,13 +1200,7 @@ export class BridgeParent {
747
1200
  const seen = new Set<string>()
748
1201
 
749
1202
  for (const injectedId of injectedIds) {
750
- // Resolve nested window path (e.g. 'tokenpocket.tron' -> window.tokenpocket.tron)
751
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
752
- const provider = injectedId.split('.').reduce<any>(
753
- (acc, part) => acc?.[part],
754
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
755
- window as any
756
- )
1203
+ const provider = this.resolveTronProviderByPath(injectedId)
757
1204
 
758
1205
  // Check if it looks like a TronProvider (has tronWeb.trx)
759
1206
  if (this.isTronProvider(provider) && !seen.has(injectedId)) {