@meshconnect/uwc-core 1.2.3-snapshot.dcfdbbf → 1.3.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.
Files changed (54) hide show
  1. package/dist/events.d.ts +40 -5
  2. package/dist/events.d.ts.map +1 -1
  3. package/dist/events.js +4 -0
  4. package/dist/events.js.map +1 -1
  5. package/dist/observability/focus-tracker.d.ts +30 -11
  6. package/dist/observability/focus-tracker.d.ts.map +1 -1
  7. package/dist/observability/focus-tracker.js +54 -18
  8. package/dist/observability/focus-tracker.js.map +1 -1
  9. package/dist/observability/instrument-handoff.d.ts.map +1 -1
  10. package/dist/observability/instrument-handoff.js +25 -6
  11. package/dist/observability/instrument-handoff.js.map +1 -1
  12. package/dist/observability/lifecycle-bridge.d.ts +13 -31
  13. package/dist/observability/lifecycle-bridge.d.ts.map +1 -1
  14. package/dist/observability/lifecycle-bridge.js +92 -167
  15. package/dist/observability/lifecycle-bridge.js.map +1 -1
  16. package/dist/observability/relay-drop-tracker.d.ts +37 -0
  17. package/dist/observability/relay-drop-tracker.d.ts.map +1 -0
  18. package/dist/observability/relay-drop-tracker.js +30 -0
  19. package/dist/observability/relay-drop-tracker.js.map +1 -0
  20. package/dist/observability/telemetry.d.ts +8 -0
  21. package/dist/observability/telemetry.d.ts.map +1 -1
  22. package/dist/observability/telemetry.js +32 -5
  23. package/dist/observability/telemetry.js.map +1 -1
  24. package/dist/services/connection-service.d.ts +0 -43
  25. package/dist/services/connection-service.d.ts.map +1 -1
  26. package/dist/services/connection-service.js +9 -163
  27. package/dist/services/connection-service.js.map +1 -1
  28. package/dist/services/network-switch-service.d.ts.map +1 -1
  29. package/dist/services/network-switch-service.js +1 -4
  30. package/dist/services/network-switch-service.js.map +1 -1
  31. package/dist/universal-wallet-connector.d.ts.map +1 -1
  32. package/dist/universal-wallet-connector.js +18 -11
  33. package/dist/universal-wallet-connector.js.map +1 -1
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/package.json +5 -5
  37. package/src/events.ts +50 -6
  38. package/src/observability/focus-tracker.test.ts +95 -8
  39. package/src/observability/focus-tracker.ts +72 -22
  40. package/src/observability/instrument-handoff.test.ts +62 -0
  41. package/src/observability/instrument-handoff.ts +37 -6
  42. package/src/observability/lifecycle-bridge.test.ts +0 -197
  43. package/src/observability/lifecycle-bridge.ts +99 -211
  44. package/src/observability/relay-drop-tracker.test.ts +84 -0
  45. package/src/observability/relay-drop-tracker.ts +53 -0
  46. package/src/observability/telemetry.test.ts +18 -0
  47. package/src/observability/telemetry.ts +41 -6
  48. package/src/services/connection-service.test.ts +0 -272
  49. package/src/services/connection-service.ts +10 -190
  50. package/src/services/network-switch-service.test.ts +0 -33
  51. package/src/services/network-switch-service.ts +1 -4
  52. package/src/universal-wallet-connector.observability.test.ts +54 -8
  53. package/src/universal-wallet-connector.ts +22 -12
  54. package/src/version.ts +1 -1
@@ -55,82 +55,6 @@ describe('attachLifecycle', () => {
55
55
  expect(got.walletSessionExpired).toEqual([routing])
56
56
  })
57
57
 
58
- it('forwards real accounts from subscribeAccounts to onAccountsChanged (a separate stream from telemetry)', () => {
59
- const em = new EventManager()
60
- let emitAccounts!: (accounts: string[]) => void
61
- const seen: string[][] = []
62
-
63
- attachLifecycle({
64
- eventManager: em,
65
- subscribe: () => () => {},
66
- subscribeAccounts: l => {
67
- emitAccounts = l
68
- return () => {}
69
- },
70
- onAccountsChanged: accounts => seen.push(accounts),
71
- getRouting: () => routing,
72
- connectionMode: 'injected',
73
- namespace: 'eip155'
74
- })
75
-
76
- emitAccounts(['0xNEW'])
77
- expect(seen).toEqual([['0xNEW']])
78
- })
79
-
80
- it('attaches subscribeAccounts even when the connector has NO telemetry subscribe (the Tron shape today)', () => {
81
- // TronConnector implements onAccountsChanged but not onLifecycleEvent —
82
- // the two streams must be independently attachable.
83
- const em = new EventManager()
84
- const unavail: unknown[] = []
85
- em.on('lifecycleTelemetryUnavailable', d => unavail.push(d))
86
- let emitAccounts!: (accounts: string[]) => void
87
- const seen: string[][] = []
88
-
89
- attachLifecycle({
90
- eventManager: em,
91
- subscribe: undefined,
92
- subscribeAccounts: l => {
93
- emitAccounts = l
94
- return () => {}
95
- },
96
- onAccountsChanged: accounts => seen.push(accounts),
97
- getRouting: () => routing,
98
- connectionMode: 'injected',
99
- namespace: 'tron'
100
- })
101
-
102
- emitAccounts(['TSwitched'])
103
- expect(seen).toEqual([['TSwitched']])
104
- // No telemetry surface exists for this connector, but that's a SEPARATE
105
- // concern from account-sync succeeding — no unavailable signal fires
106
- // just because the account stream alone is present.
107
- expect(unavail).toHaveLength(0)
108
- })
109
-
110
- it('teardown unsubscribes both streams', () => {
111
- const em = new EventManager()
112
- let lifecycleUnsubbed = false
113
- let accountsUnsubbed = false
114
-
115
- const teardown = attachLifecycle({
116
- eventManager: em,
117
- subscribe: () => () => {
118
- lifecycleUnsubbed = true
119
- },
120
- subscribeAccounts: () => () => {
121
- accountsUnsubbed = true
122
- },
123
- onAccountsChanged: () => {},
124
- getRouting: () => routing,
125
- connectionMode: 'injected',
126
- namespace: 'eip155'
127
- })
128
-
129
- teardown()
130
- expect(lifecycleUnsubbed).toBe(true)
131
- expect(accountsUnsubbed).toBe(true)
132
- })
133
-
134
58
  it('a throwing getRouting never escapes into the wallet emitter dispatch', () => {
135
59
  // Wallet SDKs dispatch listeners unguarded (e.g. TonConnect's bare
136
60
  // forEach) — a throw from our listener would abort delivery to the
@@ -228,91 +152,6 @@ describe('attachLifecycle', () => {
228
152
  expect(seen).toHaveLength(1)
229
153
  })
230
154
 
231
- it('suppresses an accountsChanged echo that matches the address a UWC switch just applied', () => {
232
- let t = 1000
233
- const em = new EventManager()
234
- const seen: string[][] = []
235
- let emitAccounts!: (accounts: string[]) => void
236
-
237
- attachLifecycle({
238
- eventManager: em,
239
- subscribe: () => () => {},
240
- subscribeAccounts: l => {
241
- emitAccounts = l
242
- return () => {}
243
- },
244
- onAccountsChanged: accounts => seen.push(accounts),
245
- getRouting: () => routing,
246
- connectionMode: 'injected',
247
- namespace: 'eip155',
248
- now: () => t
249
- })
250
-
251
- em.emit('networkSwitched', {
252
- network: { id: 'eip155:8453' } as any,
253
- address: '0xNEW'
254
- })
255
- emitAccounts(['0xNEW'])
256
- expect(seen).toHaveLength(0)
257
-
258
- // Past the echo window, the same address is a genuine wallet-side switch.
259
- t = 1000 + 5000
260
- emitAccounts(['0xNEW'])
261
- expect(seen).toHaveLength(1)
262
- })
263
-
264
- it('forwards an accountsChanged inside the echo window whose address does NOT match the switch (a real, different, user-initiated change)', () => {
265
- const em = new EventManager()
266
- const seen: string[][] = []
267
- let emitAccounts!: (accounts: string[]) => void
268
-
269
- attachLifecycle({
270
- eventManager: em,
271
- subscribe: () => () => {},
272
- subscribeAccounts: l => {
273
- emitAccounts = l
274
- return () => {}
275
- },
276
- onAccountsChanged: accounts => seen.push(accounts),
277
- getRouting: () => routing,
278
- connectionMode: 'injected',
279
- namespace: 'eip155'
280
- })
281
-
282
- em.emit('networkSwitched', {
283
- network: { id: 'eip155:8453' } as any,
284
- address: '0xNEW'
285
- })
286
- emitAccounts(['0xSOMETHING_ELSE'])
287
- expect(seen).toEqual([['0xSOMETHING_ELSE']])
288
- })
289
-
290
- it('never suppresses an empty accounts list (the disconnect signal), even inside the echo window', () => {
291
- const em = new EventManager()
292
- const seen: string[][] = []
293
- let emitAccounts!: (accounts: string[]) => void
294
-
295
- attachLifecycle({
296
- eventManager: em,
297
- subscribe: () => () => {},
298
- subscribeAccounts: l => {
299
- emitAccounts = l
300
- return () => {}
301
- },
302
- onAccountsChanged: accounts => seen.push(accounts),
303
- getRouting: () => routing,
304
- connectionMode: 'injected',
305
- namespace: 'eip155'
306
- })
307
-
308
- em.emit('networkSwitched', {
309
- network: { id: 'eip155:8453' } as any,
310
- address: '0xNEW'
311
- })
312
- emitAccounts([])
313
- expect(seen).toEqual([[]])
314
- })
315
-
316
155
  it('caps lifecycle emissions per attachment (a storming provider cannot drive unbounded egress)', () => {
317
156
  // Every other telemetry sink added by this layer is bounded (relay / bridge /
318
157
  // observer-error all cap at 100); a wallet looping chainChanged must not be
@@ -353,42 +192,6 @@ describe('attachLifecycle', () => {
353
192
  expect(unavail[0]?.reason).toBe('emission_cap')
354
193
  })
355
194
 
356
- it('does not drop a still-pending echo suppression when a SECOND switchNetwork arms before the FIRST wallet echo arrives', () => {
357
- // Two rapid, back-to-back switches (A then B) each arm their OWN echo
358
- // window. A's wallet echo can legitimately arrive AFTER B's switch has
359
- // already completed — B arming/re-arming must not erase A's still-valid,
360
- // not-yet-consumed suppression window.
361
- let t = 1000
362
- const em = new EventManager()
363
- const seen: unknown[] = []
364
- em.on('walletChainChanged', d => seen.push(d))
365
-
366
- let emit!: (e: WalletLifecycleEvent) => void
367
- attachLifecycle({
368
- eventManager: em,
369
- subscribe: l => {
370
- emit = l
371
- return () => {}
372
- },
373
- getRouting: () => routing,
374
- connectionMode: 'injected',
375
- namespace: 'eip155',
376
- now: () => t
377
- })
378
-
379
- em.emit('networkSwitched', switchedTo('eip155:8453')) // switch to A
380
- t = 1010
381
- em.emit('networkSwitched', switchedTo('eip155:137')) // switch to B, A's echo still pending
382
-
383
- t = 1020
384
- emit({ type: 'chainChanged', chainId: 'eip155:8453' }) // A's delayed echo
385
- expect(seen).toHaveLength(0)
386
-
387
- t = 1030
388
- emit({ type: 'chainChanged', chainId: 'eip155:137' }) // B's delayed echo
389
- expect(seen).toHaveLength(0)
390
- })
391
-
392
195
  it('seeds echo suppression from initialSwitch (the reattach-after-switch path)', () => {
393
196
  // A cross-namespace switchNetwork re-attaches the subscription AFTER the
394
197
  // switch completed — the fresh closure never saw that switch's bus events,
@@ -29,23 +29,11 @@ export interface AttachLifecycleOptions {
29
29
  * The active connector's `onLifecycleEvent` (already bound). `undefined` when the
30
30
  * connector can't observe lifecycle at all; the value it returns is `undefined`
31
31
  * when THIS session can't be observed (e.g. no live `.on`). Either way the
32
- * bridge emits `lifecycleTelemetryUnavailable` and becomes a no-op FOR THIS
33
- * STREAM — `subscribeAccounts` below is attached independently.
32
+ * bridge emits `lifecycleTelemetryUnavailable` and becomes a no-op.
34
33
  */
35
34
  subscribe:
36
35
  | ((l: (e: WalletLifecycleEvent) => void) => (() => void) | undefined)
37
36
  | undefined
38
- /**
39
- * The active connector's `onAccountsChanged` (already bound), carrying the
40
- * REAL account list — a separate concern from `subscribe` above (some
41
- * connectors, e.g. Tron today, implement one without the other). Absent
42
- * entirely when the connector doesn't support real account-sync.
43
- */
44
- subscribeAccounts?:
45
- | ((l: (accounts: string[]) => void) => (() => void) | undefined)
46
- | undefined
47
- /** Invoked with the resolved (post-echo-filter) real account list whenever `subscribeAccounts` delivers one. Required whenever `subscribeAccounts` is provided. */
48
- onAccountsChanged?: (accounts: string[]) => void
49
37
  /** Resolved per-event so a mid-session account/chain change is reflected. */
50
38
  getRouting: () => LifecycleRouting
51
39
  connectionMode: ConnectionMode
@@ -54,9 +42,8 @@ export interface AttachLifecycleOptions {
54
42
  /** Direct page vs production iframe — stamped on `lifecycleTelemetryUnavailable`. */
55
43
  runtime?: 'direct' | 'iframe'
56
44
  /**
57
- * Window (ms) within which a wallet `chainChanged`/`accountsChanged` is
58
- * treated as the echo of a UWC-initiated `switchNetwork` and suppressed.
59
- * Default 3000.
45
+ * Window (ms) within which a wallet `chainChanged` is treated as the echo of a
46
+ * UWC-initiated `switchNetwork` and suppressed. Default 3000.
60
47
  */
61
48
  echoWindowMs?: number
62
49
  /**
@@ -66,37 +53,29 @@ export interface AttachLifecycleOptions {
66
53
  * provider's echo would otherwise surface as wallet-initiated. Same one-shot
67
54
  * consume semantics as a bus-armed switch.
68
55
  */
69
- initialSwitch?:
70
- | { chainId: NetworkId; address?: string; at: number }
71
- | undefined
56
+ initialSwitch?: { chainId: NetworkId; at: number } | undefined
72
57
  now?: () => number
73
58
  }
74
59
 
75
60
  /**
76
61
  * Bridge a connector's PII-safe `WalletLifecycleEvent` stream onto the event bus
77
62
  * as `walletAccountChanged` / `walletChainChanged` / `walletDisconnected` /
78
- * `walletSessionExpired`, AND (separately) forward the connector's real
79
- * account list to `onAccountsChanged` for session-sync.
63
+ * `walletSessionExpired`.
80
64
  *
81
- * Three correctness concerns are handled here:
65
+ * Two correctness concerns are handled here:
82
66
  * - **Echo suppression.** When UWC drives a `switchNetwork`, the wallet emits its
83
- * own `chainChanged`/`accountsChanged` for the same change — that's already
84
- * captured by UWC's `networkSwitched` / the switch handoff, so re-emitting it
85
- * would double-count. We watch the bus for `networkSwitched` and drop a
86
- * matching echo of either kind inside `echoWindowMs`.
87
- * - **Availability is signal, per stream.** If the connector can't observe
88
- * telemetry lifecycle, we emit `lifecycleTelemetryUnavailable` ONCE but
89
- * that signal is specific to the TELEMETRY stream. A connector missing
90
- * `onLifecycleEvent` but implementing `onAccountsChanged` (e.g. Tron today)
91
- * still gets real account-sync; the two streams are independently attached.
92
- * - **Shared emission budget.** Both streams count against the same
93
- * `MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT` ceiling — a storming provider
94
- * doesn't get double budget just because two streams exist.
67
+ * own `chainChanged` for the same chain — that's already captured by UWC's
68
+ * `networkSwitched` / the switch handoff, so re-emitting it as a wallet-
69
+ * initiated `walletChainChanged` would double-count. We watch the bus for
70
+ * `networkSwitched` and drop a matching `chainChanged` inside `echoWindowMs`.
71
+ * - **Availability is signal.** If the connector can't observe lifecycle, we emit
72
+ * `lifecycleTelemetryUnavailable` ONCE so a dashboard can tell "silent" from
73
+ * "unwired".
95
74
  *
96
- * @returns a teardown that removes both connector subscriptions AND the bus watcher.
75
+ * @returns a teardown that removes the connector subscription AND the bus watcher.
97
76
  */
98
77
  export function attachLifecycle(opts: AttachLifecycleOptions): () => void {
99
- const { eventManager, subscribe, subscribeAccounts, getRouting } = opts
78
+ const { eventManager, subscribe, getRouting } = opts
100
79
  const runtime = opts.runtime ?? 'direct'
101
80
  const now = opts.now ?? Date.now
102
81
  const echoWindowMs = opts.echoWindowMs ?? 3000
@@ -110,11 +89,11 @@ export function attachLifecycle(opts: AttachLifecycleOptions): () => void {
110
89
  runtime
111
90
  })
112
91
 
113
- if (!subscribe && !subscribeAccounts) {
92
+ if (!subscribe) {
114
93
  // In the production iframe the injected subscription is deliberately not
115
94
  // attempted (the provider is a Comlink proxy) — the BRIDGE, not the connector,
116
- // is why lifecycle is unobservable. On the direct page, a connector with
117
- // neither stream simply has no lifecycle support for this namespace yet.
95
+ // is why lifecycle is unobservable. On the direct page, a connector with no
96
+ // `onLifecycleEvent` simply has no lifecycle support for this namespace yet.
118
97
  emitUnavailable(
119
98
  runtime === 'iframe'
120
99
  ? 'bridge_subscription_unavailable'
@@ -123,206 +102,115 @@ export function attachLifecycle(opts: AttachLifecycleOptions): () => void {
123
102
  return () => {}
124
103
  }
125
104
 
126
- // Record UWC-initiated switches so a wallet echo can be dropped. Armed from
127
- // BOTH signals: `awaitingWallet` (operation switchNetwork) arms at the
128
- // handoff START, because some providers emit their chainChanged BEFORE the
129
- // switch request resolves, and `networkSwitched` re-arms at completion
130
- // (also carrying the resolved address, for the accounts echo check — the
131
- // target address isn't known before the switch resolves, so there is no
132
- // early-arm equivalent for the address half).
133
- //
134
- // A LIST, not a single slot: two rapid, back-to-back switches (A then B)
135
- // each get their own pending entry. A single slot would have the SECOND
136
- // switch's arm silently erase the FIRST switch's still-unconsumed window,
137
- // so a delayed wallet echo for A (arriving after B has already completed)
138
- // would be misclassified as a genuine wallet-initiated change instead of
139
- // the suppressed echo it actually is.
140
- type PendingSwitch = {
141
- chainId?: NetworkId | undefined
142
- address?: string | undefined
143
- at: number
144
- }
145
- let pendingSwitches: PendingSwitch[] = opts.initialSwitch
146
- ? [opts.initialSwitch]
147
- : []
148
- const pruneExpired = () => {
149
- pendingSwitches = pendingSwitches.filter(p => now() - p.at <= echoWindowMs)
150
- }
151
- // `awaitingWallet` then `networkSwitched` are usually the START and
152
- // COMPLETION of the SAME switch — re-arming updates that existing entry
153
- // (refreshing `at`, adding the resolved address) rather than pushing a
154
- // duplicate, which would otherwise leave a phantom entry behind after only
155
- // one of the two is consumed by a single wallet echo.
156
- const armOrRearm = (partial: {
157
- chainId?: NetworkId | undefined
158
- address?: string | undefined
159
- }) => {
160
- pruneExpired()
161
- if (partial.chainId !== undefined) {
162
- const idx = pendingSwitches.findIndex(p => p.chainId === partial.chainId)
163
- if (idx !== -1) {
164
- pendingSwitches[idx] = {
165
- ...pendingSwitches[idx]!,
166
- ...partial,
167
- at: now()
168
- }
169
- return
170
- }
171
- }
172
- pendingSwitches.push({ ...partial, at: now() })
173
- }
105
+ // Record UWC-initiated switches so the wallet's chainChanged echo can be
106
+ // dropped. Armed from BOTH signals: `awaitingWallet` (operation switchNetwork)
107
+ // arms at the handoff START, because some providers emit their chainChanged
108
+ // BEFORE the switch request resolves i.e. before `networkSwitched` exists
109
+ // and `networkSwitched` re-arms at completion for the late-echo case. The
110
+ // re-arm means one extra suppression window can follow an early echo; the
111
+ // only event it could swallow is a sub-window user re-switch to the chain
112
+ // that just became active — accepted over double-counting every early echo.
113
+ let lastSwitch: { chainId: NetworkId; at: number } | undefined =
114
+ opts.initialSwitch
174
115
  const offAwaiting = eventManager.on('awaitingWallet', data => {
175
116
  if (data.operation === 'switchNetwork' && data.chainId !== undefined) {
176
- armOrRearm({ chainId: data.chainId })
117
+ lastSwitch = { chainId: data.chainId, at: now() }
177
118
  }
178
119
  })
179
120
  const offSwitched = eventManager.on('networkSwitched', data => {
180
- armOrRearm({ chainId: data.network.id, address: data.address })
121
+ lastSwitch = { chainId: data.network.id, at: now() }
181
122
  })
182
123
  const detachBusWatchers = () => {
183
124
  offAwaiting()
184
125
  offSwitched()
185
126
  }
186
127
 
187
- // Emission budget for THIS attachment, shared across both streams —
188
- // suppressed echoes don't spend it.
189
- let emitted = 0
190
- let capNotified = false
191
- const overCap = (): boolean => {
192
- if (emitted >= MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT) {
193
- // Mark the trip ONCE — a silent cap would be indistinguishable from a
194
- // provider that simply went quiet.
195
- if (!capNotified) {
196
- capNotified = true
197
- emitUnavailable('emission_cap')
198
- }
199
- return true
200
- }
201
- return false
202
- }
203
-
204
128
  // Defense-in-depth: a connector whose subscribe THROWS (e.g. a provider whose
205
129
  // `.on` rejects an un-proxied handler across the iframe bridge) must degrade to
206
130
  // "unavailable", never break the connect path that called us.
207
131
  let unsub: (() => void) | undefined
208
- if (subscribe) {
209
- try {
210
- unsub = subscribe(e => {
211
- // The whole body is guarded: wallet SDKs dispatch their listeners
212
- // unguarded (e.g. TonConnect's bare `forEach`), so a throw here would
213
- // abort delivery to the wallet's OWN later subscribers, including one
214
- // resolving a pending connect. Telemetry must never do that.
215
- try {
216
- if (overCap()) return
217
- const routing = getRouting()
218
- switch (e.type) {
219
- case 'accountChanged':
220
- emitted++
221
- eventManager.emit('walletAccountChanged', routing)
222
- return
223
- case 'chainChanged': {
224
- pruneExpired()
225
- const idx =
226
- e.chainId !== undefined
227
- ? pendingSwitches.findIndex(
228
- p => p.chainId !== undefined && p.chainId === e.chainId
229
- )
230
- : -1
231
- if (idx !== -1) {
232
- // Consume the echo: a UWC switch produces exactly one wallet echo, so
233
- // a LATER chainChanged to the same chain inside the window is a genuine
234
- // user-initiated switch-back and must not be suppressed. Only the
235
- // chain half is consumed — a still-pending address echo (if the
236
- // switch also changed address format) must still be checked; drop the
237
- // entry entirely once nothing is left pending on it.
238
- const entry = pendingSwitches[idx]!
239
- if (entry.address !== undefined) {
240
- pendingSwitches[idx] = { ...entry, chainId: undefined }
241
- } else {
242
- pendingSwitches.splice(idx, 1)
243
- }
244
- return
245
- }
246
- emitted++
247
- eventManager.emit('walletChainChanged', {
248
- ...routing,
249
- ...(e.chainId !== undefined ? { chainId: e.chainId } : {})
250
- })
251
- return
252
- }
253
- case 'disconnect':
254
- emitted++
255
- eventManager.emit('walletDisconnected', routing)
256
- return
257
- case 'sessionExpired':
258
- emitted++
259
- eventManager.emit('walletSessionExpired', routing)
260
- return
132
+ // Emission budget for THIS attachment — suppressed echoes don't spend it.
133
+ let emitted = 0
134
+ let capNotified = false
135
+ try {
136
+ unsub = subscribe(e => {
137
+ // The whole body is guarded: wallet SDKs dispatch their listeners
138
+ // unguarded (e.g. TonConnect's bare `forEach`), so a throw here — from
139
+ // `getRouting()` or any future edit — would abort delivery to the
140
+ // wallet's OWN later subscribers, including one resolving a pending
141
+ // connect. Telemetry must never do that.
142
+ try {
143
+ if (emitted >= MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT) {
144
+ // Mark the trip ONCE — a silent cap would be indistinguishable from a
145
+ // provider that simply went quiet.
146
+ if (!capNotified) {
147
+ capNotified = true
148
+ emitUnavailable('emission_cap')
261
149
  }
262
- } catch {
263
- // Swallowed: better to drop one lifecycle record than to break the
264
- // wallet's event dispatch.
150
+ return
265
151
  }
266
- })
267
- } catch {
268
- unsub = undefined
269
- }
270
- if (!unsub) {
271
- // The connector accepted the subscribe call but this session has no
272
- // observable telemetry surface (returned undefined or threw). Over the
273
- // bridge, that absence is the bridge's doing, not the provider's.
274
- emitUnavailable(
275
- runtime === 'iframe'
276
- ? 'bridge_subscription_unavailable'
277
- : 'provider_subscription_unavailable'
278
- )
279
- }
280
- }
281
-
282
- let unsubAccounts: (() => void) | undefined
283
- if (subscribeAccounts) {
284
- try {
285
- unsubAccounts = subscribeAccounts(accounts => {
286
- try {
287
- if (overCap()) return
288
- const primary = accounts[0]
289
- pruneExpired()
290
- const idx =
291
- accounts.length > 0
292
- ? pendingSwitches.findIndex(
293
- p => p.address !== undefined && p.address === primary
294
- )
295
- : -1
296
- if (idx !== -1) {
297
- // Same one-shot consume as the chainChanged echo above, mirrored
298
- // for the address half.
299
- const entry = pendingSwitches[idx]!
300
- if (entry.chainId !== undefined) {
301
- pendingSwitches[idx] = { ...entry, address: undefined }
302
- } else {
303
- pendingSwitches.splice(idx, 1)
152
+ const routing = getRouting()
153
+ switch (e.type) {
154
+ case 'accountChanged':
155
+ emitted++
156
+ eventManager.emit('walletAccountChanged', routing)
157
+ return
158
+ case 'chainChanged': {
159
+ const isEcho =
160
+ !!lastSwitch &&
161
+ e.chainId !== undefined &&
162
+ lastSwitch.chainId === e.chainId &&
163
+ now() - lastSwitch.at <= echoWindowMs
164
+ if (isEcho) {
165
+ // Consume the echo: a UWC switch produces exactly one wallet echo, so
166
+ // a LATER chainChanged to the same chain inside the window is a genuine
167
+ // user-initiated switch-back and must not be suppressed.
168
+ lastSwitch = undefined
169
+ return
304
170
  }
171
+ emitted++
172
+ eventManager.emit('walletChainChanged', {
173
+ ...routing,
174
+ ...(e.chainId !== undefined ? { chainId: e.chainId } : {})
175
+ })
305
176
  return
306
177
  }
307
- emitted++
308
- opts.onAccountsChanged?.(accounts)
309
- } catch {
310
- // Swallowed: never break the wallet SDK's own listener dispatch.
178
+ case 'disconnect':
179
+ emitted++
180
+ eventManager.emit('walletDisconnected', routing)
181
+ return
182
+ case 'sessionExpired':
183
+ emitted++
184
+ eventManager.emit('walletSessionExpired', routing)
185
+ return
311
186
  }
312
- })
313
- } catch {
314
- unsubAccounts = undefined
315
- }
187
+ } catch {
188
+ // Swallowed: better to drop one lifecycle record than to break the
189
+ // wallet's event dispatch.
190
+ }
191
+ })
192
+ } catch {
193
+ unsub = undefined
316
194
  }
317
195
 
318
- if (!unsub && !unsubAccounts) {
196
+ // The connector accepted the subscribe call but this session has no observable
197
+ // surface (returned undefined) or threw — treat as "unavailable" and don't
198
+ // leave the bus watchers attached.
199
+ if (!unsub) {
319
200
  detachBusWatchers()
201
+ // `onLifecycleEvent` ran but exposed no live subscription surface for this
202
+ // session (returned undefined or threw). Over the bridge, that absence is the
203
+ // bridge's doing, not the provider's.
204
+ emitUnavailable(
205
+ runtime === 'iframe'
206
+ ? 'bridge_subscription_unavailable'
207
+ : 'provider_subscription_unavailable'
208
+ )
320
209
  return () => {}
321
210
  }
322
211
 
323
212
  return () => {
324
213
  detachBusWatchers()
325
- unsub?.()
326
- unsubAccounts?.()
214
+ unsub()
327
215
  }
328
216
  }