@meshconnect/uwc-core 1.2.3-snapshot.bd9aae7 → 1.2.3-snapshot.dcfdbbf

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 (44) hide show
  1. package/dist/events.d.ts +5 -42
  2. package/dist/events.d.ts.map +1 -1
  3. package/dist/events.js +0 -4
  4. package/dist/events.js.map +1 -1
  5. package/dist/observability/focus-tracker.d.ts +12 -25
  6. package/dist/observability/focus-tracker.d.ts.map +1 -1
  7. package/dist/observability/focus-tracker.js +19 -42
  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 +6 -24
  11. package/dist/observability/instrument-handoff.js.map +1 -1
  12. package/dist/observability/lifecycle-bridge.d.ts +31 -13
  13. package/dist/observability/lifecycle-bridge.d.ts.map +1 -1
  14. package/dist/observability/lifecycle-bridge.js +167 -92
  15. package/dist/observability/lifecycle-bridge.js.map +1 -1
  16. package/dist/observability/telemetry.d.ts +0 -8
  17. package/dist/observability/telemetry.d.ts.map +1 -1
  18. package/dist/observability/telemetry.js +5 -49
  19. package/dist/observability/telemetry.js.map +1 -1
  20. package/dist/services/connection-service.d.ts +43 -0
  21. package/dist/services/connection-service.d.ts.map +1 -1
  22. package/dist/services/connection-service.js +163 -9
  23. package/dist/services/connection-service.js.map +1 -1
  24. package/dist/services/network-switch-service.d.ts.map +1 -1
  25. package/dist/services/network-switch-service.js +4 -1
  26. package/dist/services/network-switch-service.js.map +1 -1
  27. package/dist/universal-wallet-connector.d.ts.map +1 -1
  28. package/dist/universal-wallet-connector.js +1 -7
  29. package/dist/universal-wallet-connector.js.map +1 -1
  30. package/package.json +5 -5
  31. package/src/events.ts +6 -50
  32. package/src/observability/focus-tracker.test.ts +8 -60
  33. package/src/observability/focus-tracker.ts +23 -59
  34. package/src/observability/instrument-handoff.test.ts +0 -42
  35. package/src/observability/instrument-handoff.ts +6 -26
  36. package/src/observability/lifecycle-bridge.test.ts +197 -0
  37. package/src/observability/lifecycle-bridge.ts +211 -99
  38. package/src/observability/telemetry.test.ts +0 -35
  39. package/src/observability/telemetry.ts +6 -58
  40. package/src/services/connection-service.test.ts +272 -0
  41. package/src/services/connection-service.ts +190 -10
  42. package/src/services/network-switch-service.test.ts +33 -0
  43. package/src/services/network-switch-service.ts +4 -1
  44. package/src/universal-wallet-connector.ts +1 -11
@@ -88,57 +88,17 @@ describe('trackFocusDuringHandoff', () => {
88
88
 
89
89
  const { doc, fire } = makeFakeDoc()
90
90
  const { win } = makeFakeWin()
91
- let t = 1000
92
- trackFocusDuringHandoff(em, 'h-1', 'mobile', doc, win, () => t)
91
+ trackFocusDuringHandoff(em, 'h-1', 'mobile', doc, win)
93
92
 
94
93
  doc.visibilityState = 'hidden'
95
94
  doc.hidden = true
96
- fire('visibilitychange') // hidden at t=1000
97
- t = 1700
98
- doc.visibilityState = 'visible'
99
- doc.hidden = false
100
- fire('visibilitychange') // visible at t=1700 → 700ms hidden
101
-
102
- expect(hidden).toEqual([{ handoffId: 'h-1', platform: 'mobile' }])
103
- expect(visible).toEqual([
104
- { handoffId: 'h-1', platform: 'mobile', hiddenMs: 700 }
105
- ])
106
- })
107
-
108
- it('summary() reports background count + cumulative hidden time, including an open segment', () => {
109
- const em = new EventManager()
110
- const { doc, fire } = makeFakeDoc()
111
- let t = 0
112
- const tracker = trackFocusDuringHandoff(
113
- em,
114
- 'h-sum',
115
- 'mobile',
116
- doc,
117
- undefined,
118
- () => t
119
- )
120
-
121
- // First hide→show: 100 → 600 = 500ms hidden.
122
- t = 100
123
- doc.visibilityState = 'hidden'
124
95
  fire('visibilitychange')
125
- t = 600
126
96
  doc.visibilityState = 'visible'
97
+ doc.hidden = false
127
98
  fire('visibilitychange')
128
- expect(tracker.summary()).toEqual({
129
- backgroundCount: 1,
130
- backgroundedMs: 500
131
- })
132
99
 
133
- // Second hide, still hidden when summary() runs at t=1000 → +200 open segment.
134
- t = 800
135
- doc.visibilityState = 'hidden'
136
- fire('visibilitychange')
137
- t = 1000
138
- expect(tracker.summary()).toEqual({
139
- backgroundCount: 2,
140
- backgroundedMs: 700
141
- })
100
+ expect(hidden).toEqual([{ handoffId: 'h-1', platform: 'mobile' }])
101
+ expect(visible).toEqual([{ handoffId: 'h-1', platform: 'mobile' }])
142
102
  })
143
103
 
144
104
  it('treats a bfcache pageshow ON THE WINDOW as a "returned" signal', () => {
@@ -177,13 +137,7 @@ describe('trackFocusDuringHandoff', () => {
177
137
  em.on('pageVisibleDuringHandoff', e => visible.push(e))
178
138
 
179
139
  const { win, fire } = makeFakeWin()
180
- const { stop } = trackFocusDuringHandoff(
181
- em,
182
- 'h-2c',
183
- 'mobile',
184
- undefined,
185
- win
186
- )
140
+ const stop = trackFocusDuringHandoff(em, 'h-2c', 'mobile', undefined, win)
187
141
  fire('pageshow')
188
142
 
189
143
  expect(visible).toEqual([{ handoffId: 'h-2c', platform: 'mobile' }])
@@ -196,13 +150,7 @@ describe('trackFocusDuringHandoff', () => {
196
150
  em.on('pageHiddenDuringHandoff', e => hidden.push(e))
197
151
 
198
152
  const { doc, fire } = makeFakeDoc()
199
- const { stop } = trackFocusDuringHandoff(
200
- em,
201
- 'h-2d',
202
- 'desktop',
203
- doc,
204
- undefined
205
- )
153
+ const stop = trackFocusDuringHandoff(em, 'h-2d', 'desktop', doc, undefined)
206
154
  doc.visibilityState = 'hidden'
207
155
  fire('visibilitychange')
208
156
 
@@ -218,7 +166,7 @@ describe('trackFocusDuringHandoff', () => {
218
166
 
219
167
  const { doc, fire: fireDoc } = makeFakeDoc()
220
168
  const { win, listeners: winListeners, fire: fireWin } = makeFakeWin()
221
- const { stop } = trackFocusDuringHandoff(em, 'h-3', 'desktop', doc, win)
169
+ const stop = trackFocusDuringHandoff(em, 'h-3', 'desktop', doc, win)
222
170
  stop()
223
171
 
224
172
  doc.visibilityState = 'hidden'
@@ -231,7 +179,7 @@ describe('trackFocusDuringHandoff', () => {
231
179
 
232
180
  it('is a no-op (no throw) when there is neither document nor window (SSR)', () => {
233
181
  const em = new EventManager()
234
- const { stop } = trackFocusDuringHandoff(
182
+ const stop = trackFocusDuringHandoff(
235
183
  em,
236
184
  'h-4',
237
185
  'unknown',
@@ -35,52 +35,30 @@ function resolveWindow(): FocusWindowLike | undefined {
35
35
  return win && typeof win.addEventListener === 'function' ? win : undefined
36
36
  }
37
37
 
38
- /** Handle returned by `trackFocusDuringHandoff`. */
39
- export interface FocusHandoffTracker {
40
- /** Remove the visibility listeners. Always call on settle (no-op if none attached). */
41
- stop: () => void
42
- /**
43
- * Backgrounding summary at call time: how many times the page was hidden and the
44
- * cumulative hidden duration (ms) during the handoff — including an in-progress
45
- * hidden segment if the handoff settles while still backgrounded.
46
- */
47
- summary: () => { backgroundCount: number; backgroundedMs: number }
48
- }
49
-
50
38
  /**
51
39
  * While a wallet handoff is pending, emit a FACT on every tab-visibility
52
40
  * transition — `pageHiddenDuringHandoff` when the page is backgrounded (the user
53
- * likely left for the wallet app), `pageVisibleDuringHandoff` (with the segment's
54
- * `hiddenMs`) when it returns (including a bfcache `pageshow`, which iOS uses when
55
- * coming back from a wallet).
41
+ * likely left for the wallet app), `pageVisibleDuringHandoff` when it returns
42
+ * (including a bfcache `pageshow`, which iOS uses when coming back from a wallet).
43
+ *
44
+ * This is the raw signal behind "did the user actually app-switch to the wallet?"
45
+ * for deep-link flows — it does NOT interpret abandonment. The listeners are
46
+ * scoped to the single handoff: attach right after `awaitingWallet`, and the
47
+ * returned teardown (called the instant the op settles) removes them, so the
48
+ * page's visibility is only observed during the blind window. SSR-safe (no-op
49
+ * when there is neither `document` nor `window`).
56
50
  *
57
- * This is the raw signal behind "did the user actually app-switch to the wallet,
58
- * and for how long?" for deep-link flows it does NOT interpret abandonment. The
59
- * listeners are scoped to the single handoff: attach right after `awaitingWallet`,
60
- * and `stop()` (called the instant the op settles) removes them, so the page's
61
- * visibility is only observed during the blind window. SSR-safe (no-op when there
62
- * is neither `document` nor `window`). `summary()` lets the handoff stamp the
63
- * cumulative backgrounding onto its terminal.
51
+ * @returns a teardown that removes the listeners. Always call it (even when
52
+ * nothing was attached it's a no-op then) to keep the call site symmetric.
64
53
  */
65
54
  export function trackFocusDuringHandoff(
66
55
  eventManager: EventManager,
67
56
  handoffId: string,
68
57
  platform: Platform,
69
58
  doc: FocusDocumentLike | undefined = resolveDocument(),
70
- win: FocusWindowLike | undefined = resolveWindow(),
71
- now: () => number = Date.now
72
- ): FocusHandoffTracker {
73
- let backgroundCount = 0
74
- let totalHiddenMs = 0
75
- let hiddenSince: number | undefined
76
-
77
- const summary = (): { backgroundCount: number; backgroundedMs: number } => ({
78
- backgroundCount,
79
- backgroundedMs:
80
- totalHiddenMs + (hiddenSince !== undefined ? now() - hiddenSince : 0)
81
- })
82
-
83
- if (!doc && !win) return { stop: () => {}, summary }
59
+ win: FocusWindowLike | undefined = resolveWindow()
60
+ ): () => void {
61
+ if (!doc && !win) return () => {}
84
62
 
85
63
  // One physical return can fire BOTH `pageshow` (window) and a
86
64
  // `visibilitychange`→visible (document) — an iOS bfcache restore does. Dedupe
@@ -89,23 +67,12 @@ export function trackFocusDuringHandoff(
89
67
  const emitState = (state: 'hidden' | 'visible') => {
90
68
  if (state === lastState) return
91
69
  lastState = state
92
- if (state === 'hidden') {
93
- hiddenSince = now()
94
- backgroundCount++
95
- eventManager.emit('pageHiddenDuringHandoff', { handoffId, platform })
96
- return
97
- }
98
- let hiddenMs: number | undefined
99
- if (hiddenSince !== undefined) {
100
- hiddenMs = now() - hiddenSince
101
- totalHiddenMs += hiddenMs
102
- hiddenSince = undefined
103
- }
104
- eventManager.emit('pageVisibleDuringHandoff', {
105
- handoffId,
106
- platform,
107
- ...(hiddenMs !== undefined ? { hiddenMs } : {})
108
- })
70
+ eventManager.emit(
71
+ state === 'hidden'
72
+ ? 'pageHiddenDuringHandoff'
73
+ : 'pageVisibleDuringHandoff',
74
+ { handoffId, platform }
75
+ )
109
76
  }
110
77
 
111
78
  const onVisibility = () => {
@@ -122,11 +89,8 @@ export function trackFocusDuringHandoff(
122
89
  doc?.addEventListener('visibilitychange', onVisibility)
123
90
  win?.addEventListener('pageshow', onPageShow)
124
91
 
125
- return {
126
- stop: () => {
127
- doc?.removeEventListener('visibilitychange', onVisibility)
128
- win?.removeEventListener('pageshow', onPageShow)
129
- },
130
- summary
92
+ return () => {
93
+ doc?.removeEventListener('visibilitychange', onVisibility)
94
+ win?.removeEventListener('pageshow', onPageShow)
131
95
  }
132
96
  }
@@ -47,48 +47,6 @@ describe('instrumentHandoff', () => {
47
47
  expect(end.timedOut).toBe(false)
48
48
  })
49
49
 
50
- it('counts relay-socket drops seen during the handoff onto the terminal', async () => {
51
- const em = new EventManager()
52
- const seen = record(em)
53
- const wcCtx: HandoffContext = { ...CTX, connectionMode: 'walletConnect' }
54
- await instrumentHandoff(
55
- { eventManager: em, timeoutMs: 0 },
56
- wcCtx,
57
- async () => {
58
- // The relay socket drops (and recovers) while the sign request is pending.
59
- em.emit('relayDisconnected', {})
60
- em.emit('relayReconnected', undefined)
61
- em.emit('relayDisconnected', {})
62
- return 'sig'
63
- }
64
- )
65
- const end = seen.find(s => s.name === 'walletSucceeded')?.data as {
66
- relayDropCount: number
67
- backgroundCount: number
68
- backgroundedMs: number
69
- }
70
- expect(end.relayDropCount).toBe(2)
71
- // No backgrounding occurred in this test runtime.
72
- expect(end.backgroundCount).toBe(0)
73
- expect(end.backgroundedMs).toBe(0)
74
- })
75
-
76
- it('stops counting relay drops once the handoff settles', async () => {
77
- const em = new EventManager()
78
- const seen = record(em)
79
- await instrumentHandoff(
80
- { eventManager: em, timeoutMs: 0 },
81
- CTX,
82
- async () => 'sig'
83
- )
84
- // A drop after settle must be ignored (listener removed) and never throw.
85
- expect(() => em.emit('relayDisconnected', {})).not.toThrow()
86
- const end = seen.find(s => s.name === 'walletSucceeded')?.data as {
87
- relayDropCount: number
88
- }
89
- expect(end.relayDropCount).toBe(0)
90
- })
91
-
92
50
  it('carries targetAlreadyActive through awaitingWallet and the terminal', async () => {
93
51
  const em = new EventManager()
94
52
  const seen = record(em)
@@ -141,22 +141,7 @@ export async function instrumentHandoff<T>(
141
141
  // Observe page-visibility transitions ONLY while this handoff is pending — the
142
142
  // app-switch signal for deep-link flows (left for the wallet / came back). The
143
143
  // teardown in `finally` removes the listeners the instant the op settles.
144
- const focus = trackFocusDuringHandoff(eventManager, handoffId, platform)
145
-
146
- // Count relay-socket drops seen while this handoff is pending — the WC relay
147
- // emits `relayDisconnected` on the same bus. Lets a terminal say "recovered
148
- // after N drops" (the 88s suspend→reconnect case) without a cross-event join.
149
- let relayDropCount = 0
150
- const offRelayDrop = eventManager.on('relayDisconnected', () => {
151
- relayDropCount++
152
- })
153
-
154
- // Backgrounding + relay-drop summary, snapshotted onto each terminal so ONE
155
- // event answers "backgrounded? how long? socket dropped? recovered?".
156
- const handoffMetrics = () => {
157
- const { backgroundCount, backgroundedMs } = focus.summary()
158
- return { backgroundedMs, backgroundCount, relayDropCount }
159
- }
144
+ const stopFocus = trackFocusDuringHandoff(eventManager, handoffId, platform)
160
145
 
161
146
  // Same routing facets as `awaitingWallet` so every terminal is self-describing
162
147
  // (no handoffId self-join needed to know which wallet / chain / mode it closed).
@@ -179,8 +164,7 @@ export async function instrumentHandoff<T>(
179
164
  operation: context.operation,
180
165
  durationMs: now() - start,
181
166
  timeoutMs,
182
- ...routing,
183
- ...handoffMetrics()
167
+ ...routing
184
168
  })
185
169
  }, timeoutMs)
186
170
  : undefined
@@ -193,8 +177,7 @@ export async function instrumentHandoff<T>(
193
177
  durationMs: now() - start,
194
178
  timedOut,
195
179
  timeoutMs: timedOut ? timeoutMs : undefined,
196
- ...routing,
197
- ...handoffMetrics()
180
+ ...routing
198
181
  })
199
182
  return result
200
183
  } catch (error) {
@@ -224,8 +207,7 @@ export async function instrumentHandoff<T>(
224
207
  errorCode,
225
208
  failureKind,
226
209
  source,
227
- ...routing,
228
- ...handoffMetrics()
210
+ ...routing
229
211
  })
230
212
  } else {
231
213
  eventManager.emit('walletFailed', {
@@ -238,14 +220,12 @@ export async function instrumentHandoff<T>(
238
220
  errorCode,
239
221
  failureKind,
240
222
  source,
241
- ...routing,
242
- ...handoffMetrics()
223
+ ...routing
243
224
  })
244
225
  }
245
226
  throw error
246
227
  } finally {
247
228
  if (timer) clearTimeout(timer)
248
- offRelayDrop()
249
- focus.stop()
229
+ stopFocus()
250
230
  }
251
231
  }
@@ -55,6 +55,82 @@ 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
+
58
134
  it('a throwing getRouting never escapes into the wallet emitter dispatch', () => {
59
135
  // Wallet SDKs dispatch listeners unguarded (e.g. TonConnect's bare
60
136
  // forEach) — a throw from our listener would abort delivery to the
@@ -152,6 +228,91 @@ describe('attachLifecycle', () => {
152
228
  expect(seen).toHaveLength(1)
153
229
  })
154
230
 
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
+
155
316
  it('caps lifecycle emissions per attachment (a storming provider cannot drive unbounded egress)', () => {
156
317
  // Every other telemetry sink added by this layer is bounded (relay / bridge /
157
318
  // observer-error all cap at 100); a wallet looping chainChanged must not be
@@ -192,6 +353,42 @@ describe('attachLifecycle', () => {
192
353
  expect(unavail[0]?.reason).toBe('emission_cap')
193
354
  })
194
355
 
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
+
195
392
  it('seeds echo suppression from initialSwitch (the reattach-after-switch path)', () => {
196
393
  // A cross-namespace switchNetwork re-attaches the subscription AFTER the
197
394
  // switch completed — the fresh closure never saw that switch's bus events,