@meshconnect/uwc-core 1.0.3 → 1.1.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 (121) hide show
  1. package/README.md +247 -76
  2. package/dist/events.d.ts +241 -5
  3. package/dist/events.d.ts.map +1 -1
  4. package/dist/events.js +77 -1
  5. package/dist/events.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/logger/create-logger.d.ts +32 -0
  11. package/dist/logger/create-logger.d.ts.map +1 -0
  12. package/dist/logger/create-logger.js +79 -0
  13. package/dist/logger/create-logger.js.map +1 -0
  14. package/dist/managers/event-manager.d.ts.map +1 -1
  15. package/dist/managers/event-manager.js +5 -1
  16. package/dist/managers/event-manager.js.map +1 -1
  17. package/dist/observability/connect-observer.d.ts +33 -0
  18. package/dist/observability/connect-observer.d.ts.map +1 -0
  19. package/dist/observability/connect-observer.js +115 -0
  20. package/dist/observability/connect-observer.js.map +1 -0
  21. package/dist/observability/focus-tracker.d.ts +42 -0
  22. package/dist/observability/focus-tracker.d.ts.map +1 -0
  23. package/dist/observability/focus-tracker.js +57 -0
  24. package/dist/observability/focus-tracker.js.map +1 -0
  25. package/dist/observability/instrument-handoff.d.ts +54 -0
  26. package/dist/observability/instrument-handoff.d.ts.map +1 -0
  27. package/dist/observability/instrument-handoff.js +173 -0
  28. package/dist/observability/instrument-handoff.js.map +1 -0
  29. package/dist/observability/lifecycle-bridge.d.ts +69 -0
  30. package/dist/observability/lifecycle-bridge.d.ts.map +1 -0
  31. package/dist/observability/lifecycle-bridge.js +155 -0
  32. package/dist/observability/lifecycle-bridge.js.map +1 -0
  33. package/dist/observability/scrub.d.ts +31 -0
  34. package/dist/observability/scrub.d.ts.map +1 -0
  35. package/dist/observability/scrub.js +167 -0
  36. package/dist/observability/scrub.js.map +1 -0
  37. package/dist/observability/telemetry.d.ts +157 -0
  38. package/dist/observability/telemetry.d.ts.map +1 -0
  39. package/dist/observability/telemetry.js +373 -0
  40. package/dist/observability/telemetry.js.map +1 -0
  41. package/dist/services/connection-service.d.ts +23 -1
  42. package/dist/services/connection-service.d.ts.map +1 -1
  43. package/dist/services/connection-service.js +128 -19
  44. package/dist/services/connection-service.js.map +1 -1
  45. package/dist/services/network-switch-service.d.ts +2 -1
  46. package/dist/services/network-switch-service.d.ts.map +1 -1
  47. package/dist/services/network-switch-service.js +45 -16
  48. package/dist/services/network-switch-service.js.map +1 -1
  49. package/dist/services/signature-service.d.ts +14 -7
  50. package/dist/services/signature-service.d.ts.map +1 -1
  51. package/dist/services/signature-service.js +56 -58
  52. package/dist/services/signature-service.js.map +1 -1
  53. package/dist/services/transaction-service.d.ts +19 -5
  54. package/dist/services/transaction-service.d.ts.map +1 -1
  55. package/dist/services/transaction-service.js +55 -38
  56. package/dist/services/transaction-service.js.map +1 -1
  57. package/dist/universal-wallet-connector.d.ts +56 -1
  58. package/dist/universal-wallet-connector.d.ts.map +1 -1
  59. package/dist/universal-wallet-connector.js +207 -110
  60. package/dist/universal-wallet-connector.js.map +1 -1
  61. package/dist/utils/id.d.ts +2 -0
  62. package/dist/utils/id.d.ts.map +1 -0
  63. package/dist/utils/id.js +41 -0
  64. package/dist/utils/id.js.map +1 -0
  65. package/dist/utils/resolve-session-provider.d.ts +10 -0
  66. package/dist/utils/resolve-session-provider.d.ts.map +1 -0
  67. package/dist/utils/resolve-session-provider.js +21 -0
  68. package/dist/utils/resolve-session-provider.js.map +1 -0
  69. package/dist/utils/to-wallet-error.d.ts +35 -0
  70. package/dist/utils/to-wallet-error.d.ts.map +1 -0
  71. package/dist/utils/to-wallet-error.js +130 -0
  72. package/dist/utils/to-wallet-error.js.map +1 -0
  73. package/dist/utils/validate-wallet-session.d.ts +15 -0
  74. package/dist/utils/validate-wallet-session.d.ts.map +1 -0
  75. package/dist/utils/validate-wallet-session.js +29 -0
  76. package/dist/utils/validate-wallet-session.js.map +1 -0
  77. package/dist/version.d.ts +2 -0
  78. package/dist/version.d.ts.map +1 -0
  79. package/dist/version.js +3 -0
  80. package/dist/version.js.map +1 -0
  81. package/package.json +6 -6
  82. package/src/events.ts +326 -14
  83. package/src/index.ts +10 -0
  84. package/src/logger/create-logger.test.ts +126 -0
  85. package/src/logger/create-logger.ts +112 -0
  86. package/src/managers/event-manager.test.ts +55 -0
  87. package/src/managers/event-manager.ts +5 -1
  88. package/src/observability/connect-observer.test.ts +214 -0
  89. package/src/observability/connect-observer.ts +128 -0
  90. package/src/observability/focus-tracker.test.ts +191 -0
  91. package/src/observability/focus-tracker.ts +96 -0
  92. package/src/observability/instrument-handoff.test.ts +397 -0
  93. package/src/observability/instrument-handoff.ts +231 -0
  94. package/src/observability/lifecycle-bridge.test.ts +398 -0
  95. package/src/observability/lifecycle-bridge.ts +216 -0
  96. package/src/observability/scrub.test.ts +183 -0
  97. package/src/observability/scrub.ts +174 -0
  98. package/src/observability/telemetry.test.ts +549 -0
  99. package/src/observability/telemetry.ts +546 -0
  100. package/src/services/connection-service.test.ts +261 -1
  101. package/src/services/connection-service.ts +167 -38
  102. package/src/services/network-switch-service.test.ts +51 -1
  103. package/src/services/network-switch-service.ts +56 -24
  104. package/src/services/signature-service.test.ts +181 -76
  105. package/src/services/signature-service.ts +75 -85
  106. package/src/services/transaction-service.test.ts +270 -69
  107. package/src/services/transaction-service.ts +74 -51
  108. package/src/universal-wallet-connector.instrumentation-guard.test.ts +199 -0
  109. package/src/universal-wallet-connector.observability.test.ts +610 -0
  110. package/src/universal-wallet-connector.test.ts +27 -146
  111. package/src/universal-wallet-connector.ts +279 -142
  112. package/src/utils/id.test.ts +15 -0
  113. package/src/utils/id.ts +48 -0
  114. package/src/utils/resolve-session-provider.test.ts +54 -0
  115. package/src/utils/resolve-session-provider.ts +25 -0
  116. package/src/utils/to-wallet-error.test.ts +181 -0
  117. package/src/utils/to-wallet-error.ts +152 -0
  118. package/src/utils/validate-wallet-session.test.ts +77 -0
  119. package/src/utils/validate-wallet-session.ts +40 -0
  120. package/src/version.test.ts +11 -0
  121. package/src/version.ts +2 -0
@@ -0,0 +1,398 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { WalletLifecycleEvent, Network } from '@meshconnect/uwc-types'
3
+ import { EventManager } from '../managers/event-manager'
4
+ import {
5
+ attachLifecycle,
6
+ MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT,
7
+ type LifecycleRouting
8
+ } from './lifecycle-bridge'
9
+
10
+ const routing: LifecycleRouting = {
11
+ connectionMode: 'injected',
12
+ walletId: 'metamask',
13
+ namespace: 'eip155'
14
+ }
15
+
16
+ const switchedTo = (id: string) =>
17
+ ({ network: { id } as unknown as Network }) as { network: Network }
18
+
19
+ describe('attachLifecycle', () => {
20
+ it('maps lifecycle events to bus events with routing (no address)', () => {
21
+ const em = new EventManager()
22
+ const got: Record<string, unknown[]> = {}
23
+ for (const n of [
24
+ 'walletAccountChanged',
25
+ 'walletChainChanged',
26
+ 'walletDisconnected',
27
+ 'walletSessionExpired'
28
+ ] as const) {
29
+ em.on(n, d => (got[n] ??= []).push(d))
30
+ }
31
+
32
+ let emit!: (e: WalletLifecycleEvent) => void
33
+ attachLifecycle({
34
+ eventManager: em,
35
+ subscribe: l => {
36
+ emit = l
37
+ return () => {}
38
+ },
39
+ getRouting: () => routing,
40
+ connectionMode: 'injected',
41
+ walletId: 'metamask',
42
+ namespace: 'eip155'
43
+ })
44
+
45
+ emit({ type: 'accountChanged' })
46
+ emit({ type: 'chainChanged', chainId: 'eip155:8453' })
47
+ emit({ type: 'disconnect' })
48
+ emit({ type: 'sessionExpired' })
49
+
50
+ expect(got.walletAccountChanged).toEqual([routing])
51
+ expect(got.walletChainChanged).toEqual([
52
+ { ...routing, chainId: 'eip155:8453' }
53
+ ])
54
+ expect(got.walletDisconnected).toEqual([routing])
55
+ expect(got.walletSessionExpired).toEqual([routing])
56
+ })
57
+
58
+ it('a throwing getRouting never escapes into the wallet emitter dispatch', () => {
59
+ // Wallet SDKs dispatch listeners unguarded (e.g. TonConnect's bare
60
+ // forEach) — a throw from our listener would abort delivery to the
61
+ // wallet's own later subscribers.
62
+ const em = new EventManager()
63
+ let emit!: (e: WalletLifecycleEvent) => void
64
+ attachLifecycle({
65
+ eventManager: em,
66
+ subscribe: l => {
67
+ emit = l
68
+ return () => {}
69
+ },
70
+ getRouting: () => {
71
+ throw new Error('poisoned routing')
72
+ },
73
+ connectionMode: 'injected',
74
+ walletId: 'metamask',
75
+ namespace: 'eip155'
76
+ })
77
+
78
+ expect(() => emit({ type: 'accountChanged' })).not.toThrow()
79
+ })
80
+
81
+ it('suppresses a chainChanged that echoes a UWC-initiated switch', () => {
82
+ let t = 1000
83
+ const em = new EventManager()
84
+ const seen: unknown[] = []
85
+ em.on('walletChainChanged', d => seen.push(d))
86
+
87
+ let emit!: (e: WalletLifecycleEvent) => void
88
+ attachLifecycle({
89
+ eventManager: em,
90
+ subscribe: l => {
91
+ emit = l
92
+ return () => {}
93
+ },
94
+ getRouting: () => routing,
95
+ connectionMode: 'injected',
96
+ namespace: 'eip155',
97
+ now: () => t
98
+ })
99
+
100
+ // UWC drives the switch → networkSwitched on the bus; the wallet's echo for
101
+ // the same chain inside the window is dropped.
102
+ em.emit('networkSwitched', switchedTo('eip155:8453'))
103
+ emit({ type: 'chainChanged', chainId: 'eip155:8453' })
104
+ expect(seen).toHaveLength(0)
105
+
106
+ // The same chainChanged past the echo window is a genuine wallet-side switch.
107
+ t = 1000 + 5000
108
+ emit({ type: 'chainChanged', chainId: 'eip155:8453' })
109
+ expect(seen).toHaveLength(1)
110
+ })
111
+
112
+ it('suppresses an echo that lands BEFORE networkSwitched (provider emits early)', () => {
113
+ // Some providers emit their own chainChanged BEFORE the switch request
114
+ // resolves — i.e. before UWC emits networkSwitched. The suppression window
115
+ // is therefore ALSO armed from the switch handoff start (awaitingWallet
116
+ // with operation switchNetwork), which always precedes the wallet call.
117
+ let t = 1000
118
+ const em = new EventManager()
119
+ const seen: unknown[] = []
120
+ em.on('walletChainChanged', d => seen.push(d))
121
+
122
+ let emit!: (e: WalletLifecycleEvent) => void
123
+ attachLifecycle({
124
+ eventManager: em,
125
+ subscribe: l => {
126
+ emit = l
127
+ return () => {}
128
+ },
129
+ getRouting: () => routing,
130
+ connectionMode: 'injected',
131
+ namespace: 'eip155',
132
+ now: () => t
133
+ })
134
+
135
+ em.emit('awaitingWallet', {
136
+ operation: 'switchNetwork',
137
+ connectionMode: 'injected',
138
+ walletId: 'metamask',
139
+ namespace: 'eip155',
140
+ chainId: 'eip155:8453',
141
+ handoffId: 'h-switch',
142
+ walletFlowType: 'browser_extension_flow',
143
+ platform: 'desktop'
144
+ })
145
+ // The wallet's early echo — no networkSwitched has fired yet.
146
+ emit({ type: 'chainChanged', chainId: 'eip155:8453' })
147
+ expect(seen).toHaveLength(0)
148
+
149
+ // A LATER user-initiated switch-back to the same chain must still surface.
150
+ t = 1500
151
+ emit({ type: 'chainChanged', chainId: 'eip155:8453' })
152
+ expect(seen).toHaveLength(1)
153
+ })
154
+
155
+ it('caps lifecycle emissions per attachment (a storming provider cannot drive unbounded egress)', () => {
156
+ // Every other telemetry sink added by this layer is bounded (relay / bridge /
157
+ // observer-error all cap at 100); a wallet looping chainChanged must not be
158
+ // the one unbounded path to the observer.
159
+ const em = new EventManager()
160
+ const seen: unknown[] = []
161
+ em.on('walletChainChanged', d => seen.push(d))
162
+ em.on('walletAccountChanged', d => seen.push(d))
163
+ const unavail: Array<{ reason?: string }> = []
164
+ em.on('lifecycleTelemetryUnavailable', d =>
165
+ unavail.push(d as { reason?: string })
166
+ )
167
+
168
+ let emit!: (e: WalletLifecycleEvent) => void
169
+ attachLifecycle({
170
+ eventManager: em,
171
+ subscribe: l => {
172
+ emit = l
173
+ return () => {}
174
+ },
175
+ getRouting: () => routing,
176
+ connectionMode: 'injected',
177
+ namespace: 'eip155'
178
+ })
179
+
180
+ for (let i = 0; i < MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT + 50; i++) {
181
+ emit({ type: 'chainChanged', chainId: `eip155:${i}` })
182
+ }
183
+ expect(seen).toHaveLength(MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT)
184
+
185
+ // Once capped, every lifecycle type stays silenced for this attachment…
186
+ emit({ type: 'accountChanged' })
187
+ expect(seen).toHaveLength(MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT)
188
+
189
+ // …but the trip itself is marked ONCE, so a dashboard can tell "provider
190
+ // went quiet" from "provider stormed and got capped".
191
+ expect(unavail).toHaveLength(1)
192
+ expect(unavail[0]?.reason).toBe('emission_cap')
193
+ })
194
+
195
+ it('seeds echo suppression from initialSwitch (the reattach-after-switch path)', () => {
196
+ // A cross-namespace switchNetwork re-attaches the subscription AFTER the
197
+ // switch completed — the fresh closure never saw that switch's bus events,
198
+ // so without a seed the new provider's echo would surface as wallet-initiated.
199
+ let t = 1000
200
+ const em = new EventManager()
201
+ const seen: unknown[] = []
202
+ em.on('walletChainChanged', d => seen.push(d))
203
+
204
+ let emit!: (e: WalletLifecycleEvent) => void
205
+ attachLifecycle({
206
+ eventManager: em,
207
+ subscribe: l => {
208
+ emit = l
209
+ return () => {}
210
+ },
211
+ getRouting: () => routing,
212
+ connectionMode: 'injected',
213
+ namespace: 'eip155',
214
+ initialSwitch: { chainId: 'eip155:8453', at: 1000 },
215
+ now: () => t
216
+ })
217
+
218
+ // The just-switched chain's echo is suppressed…
219
+ emit({ type: 'chainChanged', chainId: 'eip155:8453' })
220
+ expect(seen).toHaveLength(0)
221
+
222
+ // …and CONSUMED: a later same-chain change is genuine and surfaces.
223
+ t = 1500
224
+ emit({ type: 'chainChanged', chainId: 'eip155:8453' })
225
+ expect(seen).toHaveLength(1)
226
+ })
227
+
228
+ it('a connect handoff does not arm echo suppression', () => {
229
+ const em = new EventManager()
230
+ const seen: unknown[] = []
231
+ em.on('walletChainChanged', d => seen.push(d))
232
+
233
+ let emit!: (e: WalletLifecycleEvent) => void
234
+ attachLifecycle({
235
+ eventManager: em,
236
+ subscribe: l => {
237
+ emit = l
238
+ return () => {}
239
+ },
240
+ getRouting: () => routing,
241
+ connectionMode: 'injected',
242
+ namespace: 'eip155'
243
+ })
244
+
245
+ em.emit('awaitingWallet', {
246
+ operation: 'connect',
247
+ connectionMode: 'injected',
248
+ walletId: 'metamask',
249
+ namespace: 'eip155',
250
+ chainId: 'eip155:8453',
251
+ handoffId: 'h-connect',
252
+ walletFlowType: 'browser_extension_flow',
253
+ platform: 'desktop'
254
+ })
255
+ emit({ type: 'chainChanged', chainId: 'eip155:8453' })
256
+
257
+ expect(seen).toHaveLength(1)
258
+ })
259
+
260
+ it('teardown removes the awaitingWallet watcher too (no lingering bus listeners)', () => {
261
+ const em = new EventManager()
262
+ const baseline = em.getListenerCount()
263
+
264
+ const teardown = attachLifecycle({
265
+ eventManager: em,
266
+ subscribe: () => () => {},
267
+ getRouting: () => routing,
268
+ connectionMode: 'injected',
269
+ namespace: 'eip155'
270
+ })
271
+ expect(em.getListenerCount()).toBeGreaterThan(baseline)
272
+
273
+ teardown()
274
+ expect(em.getListenerCount()).toBe(baseline)
275
+ })
276
+
277
+ it('reports unsupported_namespace on the direct page when the connector has no lifecycle support', () => {
278
+ const em = new EventManager()
279
+ const unavail: unknown[] = []
280
+ em.on('lifecycleTelemetryUnavailable', d => unavail.push(d))
281
+
282
+ attachLifecycle({
283
+ eventManager: em,
284
+ subscribe: undefined,
285
+ getRouting: () => routing,
286
+ connectionMode: 'injected',
287
+ namespace: 'eip155',
288
+ walletId: 'metamask'
289
+ })
290
+
291
+ expect(unavail).toEqual([
292
+ {
293
+ reason: 'unsupported_namespace',
294
+ connectionMode: 'injected',
295
+ namespace: 'eip155',
296
+ walletId: 'metamask',
297
+ runtime: 'direct'
298
+ }
299
+ ])
300
+ })
301
+
302
+ it('reports bridge_subscription_unavailable in the iframe (bridge is why lifecycle is silent)', () => {
303
+ const em = new EventManager()
304
+ const unavail: Array<{ reason?: string; runtime?: string }> = []
305
+ em.on('lifecycleTelemetryUnavailable', d =>
306
+ unavail.push(d as { reason?: string; runtime?: string })
307
+ )
308
+
309
+ // No lifecycle surface AND running inside the Link iframe.
310
+ attachLifecycle({
311
+ eventManager: em,
312
+ subscribe: () => undefined,
313
+ getRouting: () => routing,
314
+ connectionMode: 'injected',
315
+ namespace: 'eip155',
316
+ runtime: 'iframe'
317
+ })
318
+
319
+ expect(unavail).toHaveLength(1)
320
+ expect(unavail[0]?.reason).toBe('bridge_subscription_unavailable')
321
+ expect(unavail[0]?.runtime).toBe('iframe')
322
+ })
323
+
324
+ it('emits unavailable (and does not leak the bus watchers) when subscribe returns undefined', () => {
325
+ const em = new EventManager()
326
+ const unavail: unknown[] = []
327
+ em.on('lifecycleTelemetryUnavailable', d => unavail.push(d))
328
+ const baseline = em.getListenerCount()
329
+
330
+ const teardown = attachLifecycle({
331
+ eventManager: em,
332
+ subscribe: () => undefined, // accepted the call but no observable surface
333
+ getRouting: () => routing,
334
+ connectionMode: 'injected',
335
+ namespace: 'eip155'
336
+ })
337
+
338
+ expect(unavail).toHaveLength(1)
339
+ // Every bus watcher (networkSwitched + awaitingWallet) is removed on this
340
+ // degraded path — nothing lingers for a session that can't be observed.
341
+ expect(em.getListenerCount()).toBe(baseline)
342
+ expect(() => teardown()).not.toThrow()
343
+ })
344
+
345
+ it('teardown removes the connector sub AND the networkSwitched watcher', () => {
346
+ const em = new EventManager()
347
+ let unsubbed = false
348
+ let emit!: (e: WalletLifecycleEvent) => void
349
+ const teardown = attachLifecycle({
350
+ eventManager: em,
351
+ subscribe: l => {
352
+ emit = l
353
+ return () => {
354
+ unsubbed = true
355
+ }
356
+ },
357
+ getRouting: () => routing,
358
+ connectionMode: 'injected',
359
+ namespace: 'eip155'
360
+ })
361
+
362
+ teardown()
363
+ expect(unsubbed).toBe(true)
364
+
365
+ // Watcher gone: a post-teardown networkSwitched no longer arms echo-suppression,
366
+ // so a chainChanged for that chain is NOT suppressed.
367
+ const seen: unknown[] = []
368
+ em.on('walletChainChanged', d => seen.push(d))
369
+ em.emit('networkSwitched', switchedTo('eip155:1'))
370
+ emit({ type: 'chainChanged', chainId: 'eip155:1' })
371
+ expect(seen).toHaveLength(1)
372
+ })
373
+
374
+ it('degrades to unavailable (never throws) when subscribe itself throws', () => {
375
+ // A connector whose subscribe throws — e.g. a provider whose `.on` rejects an
376
+ // un-proxied handler across the iframe bridge — must NOT break the connect
377
+ // path; it degrades to lifecycleTelemetryUnavailable.
378
+ const em = new EventManager()
379
+ const unavail: unknown[] = []
380
+ em.on('lifecycleTelemetryUnavailable', d => unavail.push(d))
381
+
382
+ let teardown!: () => void
383
+ expect(() => {
384
+ teardown = attachLifecycle({
385
+ eventManager: em,
386
+ subscribe: () => {
387
+ throw new Error('handler could not be cloned over the bridge')
388
+ },
389
+ getRouting: () => routing,
390
+ connectionMode: 'injected',
391
+ namespace: 'eip155'
392
+ })
393
+ }).not.toThrow()
394
+
395
+ expect(unavail).toHaveLength(1)
396
+ expect(() => teardown()).not.toThrow()
397
+ })
398
+ })
@@ -0,0 +1,216 @@
1
+ import type {
2
+ WalletLifecycleEvent,
3
+ ConnectionMode,
4
+ Namespace,
5
+ NetworkId,
6
+ LifecycleUnavailableReason
7
+ } from '@meshconnect/uwc-types'
8
+ import type { EventManager } from '../managers/event-manager'
9
+
10
+ /** Routing facets stamped on every lifecycle event. No address. */
11
+ export interface LifecycleRouting {
12
+ connectionMode: ConnectionMode
13
+ walletId?: string | undefined
14
+ namespace?: Namespace | undefined
15
+ }
16
+
17
+ /**
18
+ * Hard ceiling on lifecycle emissions per attachment. Every other telemetry
19
+ * sink this layer added is bounded (relay / bridge / observer-error dedup sets
20
+ * cap at 100); a provider looping `chainChanged`/`accountsChanged` must not be
21
+ * the one unbounded path to the observer. A legitimate session stays far below
22
+ * this; a storming provider goes silent instead of driving egress cost.
23
+ */
24
+ export const MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT = 100
25
+
26
+ export interface AttachLifecycleOptions {
27
+ eventManager: EventManager
28
+ /**
29
+ * The active connector's `onLifecycleEvent` (already bound). `undefined` when the
30
+ * connector can't observe lifecycle at all; the value it returns is `undefined`
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.
33
+ */
34
+ subscribe:
35
+ | ((l: (e: WalletLifecycleEvent) => void) => (() => void) | undefined)
36
+ | undefined
37
+ /** Resolved per-event so a mid-session account/chain change is reflected. */
38
+ getRouting: () => LifecycleRouting
39
+ connectionMode: ConnectionMode
40
+ namespace?: Namespace | undefined
41
+ walletId?: string | undefined
42
+ /** Direct page vs production iframe — stamped on `lifecycleTelemetryUnavailable`. */
43
+ runtime?: 'direct' | 'iframe'
44
+ /**
45
+ * Window (ms) within which a wallet `chainChanged` is treated as the echo of a
46
+ * UWC-initiated `switchNetwork` and suppressed. Default 3000.
47
+ */
48
+ echoWindowMs?: number
49
+ /**
50
+ * Seed for echo suppression when (re)attaching right AFTER a UWC-initiated
51
+ * switch completed (the cross-namespace reattach path): the fresh closure
52
+ * never saw that switch's `awaitingWallet`/`networkSwitched`, so the new
53
+ * provider's echo would otherwise surface as wallet-initiated. Same one-shot
54
+ * consume semantics as a bus-armed switch.
55
+ */
56
+ initialSwitch?: { chainId: NetworkId; at: number } | undefined
57
+ now?: () => number
58
+ }
59
+
60
+ /**
61
+ * Bridge a connector's PII-safe `WalletLifecycleEvent` stream onto the event bus
62
+ * as `walletAccountChanged` / `walletChainChanged` / `walletDisconnected` /
63
+ * `walletSessionExpired`.
64
+ *
65
+ * Two correctness concerns are handled here:
66
+ * - **Echo suppression.** When UWC drives a `switchNetwork`, the wallet emits its
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".
74
+ *
75
+ * @returns a teardown that removes the connector subscription AND the bus watcher.
76
+ */
77
+ export function attachLifecycle(opts: AttachLifecycleOptions): () => void {
78
+ const { eventManager, subscribe, getRouting } = opts
79
+ const runtime = opts.runtime ?? 'direct'
80
+ const now = opts.now ?? Date.now
81
+ const echoWindowMs = opts.echoWindowMs ?? 3000
82
+
83
+ const emitUnavailable = (reason: LifecycleUnavailableReason) =>
84
+ eventManager.emit('lifecycleTelemetryUnavailable', {
85
+ reason,
86
+ connectionMode: opts.connectionMode,
87
+ namespace: opts.namespace,
88
+ walletId: opts.walletId,
89
+ runtime
90
+ })
91
+
92
+ if (!subscribe) {
93
+ // In the production iframe the injected subscription is deliberately not
94
+ // attempted (the provider is a Comlink proxy) — the BRIDGE, not the connector,
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.
97
+ emitUnavailable(
98
+ runtime === 'iframe'
99
+ ? 'bridge_subscription_unavailable'
100
+ : 'unsupported_namespace'
101
+ )
102
+ return () => {}
103
+ }
104
+
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
115
+ const offAwaiting = eventManager.on('awaitingWallet', data => {
116
+ if (data.operation === 'switchNetwork' && data.chainId !== undefined) {
117
+ lastSwitch = { chainId: data.chainId, at: now() }
118
+ }
119
+ })
120
+ const offSwitched = eventManager.on('networkSwitched', data => {
121
+ lastSwitch = { chainId: data.network.id, at: now() }
122
+ })
123
+ const detachBusWatchers = () => {
124
+ offAwaiting()
125
+ offSwitched()
126
+ }
127
+
128
+ // Defense-in-depth: a connector whose subscribe THROWS (e.g. a provider whose
129
+ // `.on` rejects an un-proxied handler across the iframe bridge) must degrade to
130
+ // "unavailable", never break the connect path that called us.
131
+ let unsub: (() => void) | undefined
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')
149
+ }
150
+ return
151
+ }
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
170
+ }
171
+ emitted++
172
+ eventManager.emit('walletChainChanged', {
173
+ ...routing,
174
+ ...(e.chainId !== undefined ? { chainId: e.chainId } : {})
175
+ })
176
+ return
177
+ }
178
+ case 'disconnect':
179
+ emitted++
180
+ eventManager.emit('walletDisconnected', routing)
181
+ return
182
+ case 'sessionExpired':
183
+ emitted++
184
+ eventManager.emit('walletSessionExpired', routing)
185
+ return
186
+ }
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
194
+ }
195
+
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) {
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
+ )
209
+ return () => {}
210
+ }
211
+
212
+ return () => {
213
+ detachBusWatchers()
214
+ unsub()
215
+ }
216
+ }