@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
package/src/events.ts CHANGED
@@ -94,6 +94,24 @@ export interface UWCEventMap {
94
94
  error: WalletError
95
95
  }
96
96
 
97
+ /**
98
+ * The WalletConnect relay transport socket dropped (relayer `disconnect`) — e.g.
99
+ * iOS suspending the WebView during a wallet deep-link silently kills the socket.
100
+ * Distinct from `relayError` (an abnormal close that surfaced an ERROR): this is
101
+ * the lifecycle FACT of the socket going down. Emitted once per outage (WC core
102
+ * re-emits `disconnect` on each failed reconnect); pair with `relayReconnected`
103
+ * to measure the dead window that leaves an in-flight sign/connect response
104
+ * undelivered (CARE-263). The relayer passes no payload, so this carries no
105
+ * reason. Telemetry-only.
106
+ */
107
+ relayDisconnected: void
108
+ /**
109
+ * The WalletConnect relay transport reconnected after a `relayDisconnected`.
110
+ * The elapsed time since the paired disconnect is the socket's dead window.
111
+ * Telemetry-only.
112
+ */
113
+ relayReconnected: void
114
+
97
115
  /**
98
116
  * `SignClient.init` (WalletConnect) rejected — the client could not be created, so
99
117
  * no connect could proceed. Rejects on a storage (IndexedDB) or crypto init failure,
@@ -125,11 +143,8 @@ export interface UWCEventMap {
125
143
 
126
144
  /** Network switching is in progress. Fires on start and end. */
127
145
  networkSwitching: { isLoading: boolean; isWaitingForUserApproval: boolean }
128
- /** Network switch succeeded. `address` is the new address the switch
129
- * resolved to (present whenever the connector returned one) — used by
130
- * the lifecycle bridge to suppress the wallet's own echo of this same
131
- * change (see attachLifecycle's echo-suppression window). */
132
- networkSwitched: { network: Network; address?: string }
146
+ /** Network switch succeeded. */
147
+ networkSwitched: { network: Network }
133
148
 
134
149
  /** Wallet capabilities were refreshed. */
135
150
  capabilitiesUpdated: { capabilities: Record<string, EVMCapabilities> }
@@ -208,6 +223,11 @@ export interface UWCEventMap {
208
223
  timeoutMs?: number | undefined
209
224
  /** switchNetwork only: target chain already the active one (no-op switch). */
210
225
  targetAlreadyActive?: boolean | undefined
226
+ /** Backgrounding during this handoff: cumulative hidden time (ms) + transition count. */
227
+ backgroundedMs?: number | undefined
228
+ backgroundCount?: number | undefined
229
+ /** WalletConnect only: relay-socket drops seen while this handoff was pending. */
230
+ relayDropCount?: number | undefined
211
231
  }
212
232
  /** The user explicitly rejected (4001 / WalletError.type === 'rejected'). Closes a handoff. */
213
233
  walletRejected: {
@@ -225,6 +245,11 @@ export interface UWCEventMap {
225
245
  errorCode?: string | undefined
226
246
  failureKind?: FailureKind | undefined
227
247
  source?: ErrorSource | undefined
248
+ /** Backgrounding during this handoff: cumulative hidden time (ms) + transition count. */
249
+ backgroundedMs?: number | undefined
250
+ backgroundCount?: number | undefined
251
+ /** WalletConnect only: relay-socket drops seen while this handoff was pending. */
252
+ relayDropCount?: number | undefined
228
253
  }
229
254
  /** The op failed for any non-rejection reason. Closes a handoff. */
230
255
  walletFailed: {
@@ -243,6 +268,11 @@ export interface UWCEventMap {
243
268
  errorCode?: string | undefined
244
269
  failureKind?: FailureKind | undefined
245
270
  source?: ErrorSource | undefined
271
+ /** Backgrounding during this handoff: cumulative hidden time (ms) + transition count. */
272
+ backgroundedMs?: number | undefined
273
+ backgroundCount?: number | undefined
274
+ /** WalletConnect only: relay-socket drops seen while this handoff was pending. */
275
+ relayDropCount?: number | undefined
246
276
  }
247
277
  /**
248
278
  * Side marker: the deadline passed with the op still pending. OBSERVATIONAL —
@@ -262,6 +292,11 @@ export interface UWCEventMap {
262
292
  timeoutMs: number
263
293
  /** switchNetwork only: target chain already the active one (no-op switch). */
264
294
  targetAlreadyActive?: boolean | undefined
295
+ /** Backgrounding so far (marker fires mid-flight): hidden time (ms) + transition count. */
296
+ backgroundedMs?: number | undefined
297
+ backgroundCount?: number | undefined
298
+ /** WalletConnect only: relay-socket drops seen so far while this handoff was pending. */
299
+ relayDropCount?: number | undefined
265
300
  }
266
301
 
267
302
  // ---- Provider lifecycle (telemetry-only) ----------------------------------
@@ -324,7 +359,12 @@ export interface UWCEventMap {
324
359
  * handoff (a bfcache restore can fire both `pageshow` and `visibilitychange`
325
360
  * for one physical return). Telemetry-only.
326
361
  */
327
- pageVisibleDuringHandoff: { handoffId: string; platform: Platform }
362
+ pageVisibleDuringHandoff: {
363
+ handoffId: string
364
+ platform: Platform
365
+ /** Duration the page was hidden for THIS hide→show segment (ms). */
366
+ hiddenMs?: number | undefined
367
+ }
328
368
 
329
369
  /**
330
370
  * Back-compat catch-all. Fires after every other event above.
@@ -348,6 +388,8 @@ export const TELEMETRY_ONLY_EVENTS: ReadonlySet<UWCEventName> = new Set([
348
388
  'walletDiscoveryFailed',
349
389
  'bridgeError',
350
390
  'relayError',
391
+ 'relayDisconnected',
392
+ 'relayReconnected',
351
393
  'walletConnectInitError',
352
394
  'walletAccountChanged',
353
395
  'walletChainChanged',
@@ -375,6 +417,8 @@ const EVENT_NAME_FLAGS: Record<UWCEventName, true> = {
375
417
  walletDiscoveryFailed: true,
376
418
  bridgeError: true,
377
419
  relayError: true,
420
+ relayDisconnected: true,
421
+ relayReconnected: true,
378
422
  walletConnectInitError: true,
379
423
  connecting: true,
380
424
  connected: true,
@@ -88,17 +88,92 @@ describe('trackFocusDuringHandoff', () => {
88
88
 
89
89
  const { doc, fire } = makeFakeDoc()
90
90
  const { win } = makeFakeWin()
91
- trackFocusDuringHandoff(em, 'h-1', 'mobile', doc, win)
91
+ let t = 1000
92
+ trackFocusDuringHandoff(em, 'h-1', 'mobile', doc, win, () => t)
92
93
 
93
94
  doc.visibilityState = 'hidden'
94
95
  doc.hidden = true
95
- fire('visibilitychange')
96
+ fire('visibilitychange') // hidden at t=1000
97
+ t = 1700
96
98
  doc.visibilityState = 'visible'
97
99
  doc.hidden = false
98
- fire('visibilitychange')
100
+ fire('visibilitychange') // visible at t=1700 → 700ms hidden
99
101
 
100
102
  expect(hidden).toEqual([{ handoffId: 'h-1', platform: 'mobile' }])
101
- expect(visible).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
+ fire('visibilitychange')
125
+ t = 600
126
+ doc.visibilityState = 'visible'
127
+ fire('visibilitychange')
128
+ expect(tracker.summary()).toEqual({
129
+ backgroundCount: 1,
130
+ backgroundedMs: 500
131
+ })
132
+
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
+ })
142
+ })
143
+
144
+ it('records an already-hidden start as a real hidden transition (pairs 1:1, counted)', () => {
145
+ const em = new EventManager()
146
+ const hidden: Hidden[] = []
147
+ const visible: Visible[] = []
148
+ em.on('pageHiddenDuringHandoff', e => hidden.push(e))
149
+ em.on('pageVisibleDuringHandoff', e => visible.push(e))
150
+
151
+ const { doc, fire } = makeFakeDoc()
152
+ doc.visibilityState = 'hidden'
153
+ doc.hidden = true
154
+ let t = 500
155
+ const tracker = trackFocusDuringHandoff(
156
+ em,
157
+ 'h-seed',
158
+ 'mobile',
159
+ doc,
160
+ undefined,
161
+ () => t
162
+ )
163
+
164
+ // Attaching while hidden emits a REAL pageHidden so the later visible pairs 1:1.
165
+ expect(hidden).toEqual([{ handoffId: 'h-seed', platform: 'mobile' }])
166
+ expect(visible).toEqual([])
167
+ expect(tracker.summary()).toEqual({ backgroundCount: 1, backgroundedMs: 0 })
168
+
169
+ // Returning at t=900 → paired pageVisible carrying the 400ms segment.
170
+ t = 900
171
+ doc.visibilityState = 'visible'
172
+ doc.hidden = false
173
+ fire('visibilitychange')
174
+ expect(visible).toEqual([
175
+ { handoffId: 'h-seed', platform: 'mobile', hiddenMs: 400 }
176
+ ])
102
177
  })
103
178
 
104
179
  it('treats a bfcache pageshow ON THE WINDOW as a "returned" signal', () => {
@@ -137,7 +212,13 @@ describe('trackFocusDuringHandoff', () => {
137
212
  em.on('pageVisibleDuringHandoff', e => visible.push(e))
138
213
 
139
214
  const { win, fire } = makeFakeWin()
140
- const stop = trackFocusDuringHandoff(em, 'h-2c', 'mobile', undefined, win)
215
+ const { stop } = trackFocusDuringHandoff(
216
+ em,
217
+ 'h-2c',
218
+ 'mobile',
219
+ undefined,
220
+ win
221
+ )
141
222
  fire('pageshow')
142
223
 
143
224
  expect(visible).toEqual([{ handoffId: 'h-2c', platform: 'mobile' }])
@@ -150,7 +231,13 @@ describe('trackFocusDuringHandoff', () => {
150
231
  em.on('pageHiddenDuringHandoff', e => hidden.push(e))
151
232
 
152
233
  const { doc, fire } = makeFakeDoc()
153
- const stop = trackFocusDuringHandoff(em, 'h-2d', 'desktop', doc, undefined)
234
+ const { stop } = trackFocusDuringHandoff(
235
+ em,
236
+ 'h-2d',
237
+ 'desktop',
238
+ doc,
239
+ undefined
240
+ )
154
241
  doc.visibilityState = 'hidden'
155
242
  fire('visibilitychange')
156
243
 
@@ -166,7 +253,7 @@ describe('trackFocusDuringHandoff', () => {
166
253
 
167
254
  const { doc, fire: fireDoc } = makeFakeDoc()
168
255
  const { win, listeners: winListeners, fire: fireWin } = makeFakeWin()
169
- const stop = trackFocusDuringHandoff(em, 'h-3', 'desktop', doc, win)
256
+ const { stop } = trackFocusDuringHandoff(em, 'h-3', 'desktop', doc, win)
170
257
  stop()
171
258
 
172
259
  doc.visibilityState = 'hidden'
@@ -179,7 +266,7 @@ describe('trackFocusDuringHandoff', () => {
179
266
 
180
267
  it('is a no-op (no throw) when there is neither document nor window (SSR)', () => {
181
268
  const em = new EventManager()
182
- const stop = trackFocusDuringHandoff(
269
+ const { stop } = trackFocusDuringHandoff(
183
270
  em,
184
271
  'h-4',
185
272
  'unknown',
@@ -35,30 +35,58 @@ 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
+
38
50
  /**
39
51
  * While a wallet handoff is pending, emit a FACT on every tab-visibility
40
52
  * transition — `pageHiddenDuringHandoff` when the page is backgrounded (the user
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).
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).
43
56
  *
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`).
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.
50
64
  *
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.
65
+ * One non-transition case: if the tracker attaches while the page is ALREADY
66
+ * hidden (handoff initiated from a backgrounded tab), it emits an initial
67
+ * synthetic `pageHiddenDuringHandoff` at attach time so the eventual return pairs
68
+ * 1:1 and the hidden segment is counted. So `pageHiddenDuringHandoff` is "every
69
+ * transition into hidden, plus an already-hidden start", not strictly transitions.
53
70
  */
54
71
  export function trackFocusDuringHandoff(
55
72
  eventManager: EventManager,
56
73
  handoffId: string,
57
74
  platform: Platform,
58
75
  doc: FocusDocumentLike | undefined = resolveDocument(),
59
- win: FocusWindowLike | undefined = resolveWindow()
60
- ): () => void {
61
- if (!doc && !win) return () => {}
76
+ win: FocusWindowLike | undefined = resolveWindow(),
77
+ now: () => number = Date.now
78
+ ): FocusHandoffTracker {
79
+ let backgroundCount = 0
80
+ let totalHiddenMs = 0
81
+ let hiddenSince: number | undefined
82
+
83
+ const summary = (): { backgroundCount: number; backgroundedMs: number } => ({
84
+ backgroundCount,
85
+ backgroundedMs:
86
+ totalHiddenMs + (hiddenSince !== undefined ? now() - hiddenSince : 0)
87
+ })
88
+
89
+ if (!doc && !win) return { stop: () => {}, summary }
62
90
 
63
91
  // One physical return can fire BOTH `pageshow` (window) and a
64
92
  // `visibilitychange`→visible (document) — an iOS bfcache restore does. Dedupe
@@ -67,12 +95,23 @@ export function trackFocusDuringHandoff(
67
95
  const emitState = (state: 'hidden' | 'visible') => {
68
96
  if (state === lastState) return
69
97
  lastState = state
70
- eventManager.emit(
71
- state === 'hidden'
72
- ? 'pageHiddenDuringHandoff'
73
- : 'pageVisibleDuringHandoff',
74
- { handoffId, platform }
75
- )
98
+ if (state === 'hidden') {
99
+ hiddenSince = now()
100
+ backgroundCount++
101
+ eventManager.emit('pageHiddenDuringHandoff', { handoffId, platform })
102
+ return
103
+ }
104
+ let hiddenMs: number | undefined
105
+ if (hiddenSince !== undefined) {
106
+ hiddenMs = now() - hiddenSince
107
+ totalHiddenMs += hiddenMs
108
+ hiddenSince = undefined
109
+ }
110
+ eventManager.emit('pageVisibleDuringHandoff', {
111
+ handoffId,
112
+ platform,
113
+ ...(hiddenMs !== undefined ? { hiddenMs } : {})
114
+ })
76
115
  }
77
116
 
78
117
  const onVisibility = () => {
@@ -89,8 +128,19 @@ export function trackFocusDuringHandoff(
89
128
  doc?.addEventListener('visibilitychange', onVisibility)
90
129
  win?.addEventListener('pageshow', onPageShow)
91
130
 
92
- return () => {
93
- doc?.removeEventListener('visibilitychange', onVisibility)
94
- win?.removeEventListener('pageshow', onPageShow)
131
+ // Handoff started while ALREADY hidden (initiated from a backgrounded tab):
132
+ // record it as a real hidden transition so the eventual `pageVisibleDuringHandoff`
133
+ // pairs 1:1 and summary()/hiddenMs count the segment. `emitState`'s dedup makes
134
+ // the first subsequent visibilitychange→hidden a no-op.
135
+ if (doc?.visibilityState === 'hidden' || doc?.hidden === true) {
136
+ emitState('hidden')
137
+ }
138
+
139
+ return {
140
+ stop: () => {
141
+ doc?.removeEventListener('visibilitychange', onVisibility)
142
+ win?.removeEventListener('pageshow', onPageShow)
143
+ },
144
+ summary
95
145
  }
96
146
  }
@@ -47,6 +47,68 @@ 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', undefined)
60
+ em.emit('relayReconnected', undefined)
61
+ em.emit('relayDisconnected', undefined)
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
+ const wcCtx: HandoffContext = { ...CTX, connectionMode: 'walletConnect' }
80
+ await instrumentHandoff(
81
+ { eventManager: em, timeoutMs: 0 },
82
+ wcCtx,
83
+ async () => 'sig'
84
+ )
85
+ // A drop after settle must be ignored (listener removed) and never throw.
86
+ expect(() => em.emit('relayDisconnected', undefined)).not.toThrow()
87
+ const end = seen.find(s => s.name === 'walletSucceeded')?.data as {
88
+ relayDropCount: number
89
+ }
90
+ expect(end.relayDropCount).toBe(0)
91
+ })
92
+
93
+ it('omits relayDropCount for non-WalletConnect handoffs (no misattribution)', async () => {
94
+ const em = new EventManager()
95
+ const seen = record(em)
96
+ // A WC relay flap firing during an injected handoff must NOT be attributed to it.
97
+ await instrumentHandoff(
98
+ { eventManager: em, timeoutMs: 0 },
99
+ CTX,
100
+ async () => {
101
+ em.emit('relayDisconnected', undefined)
102
+ return 'sig'
103
+ }
104
+ )
105
+ const end = seen.find(s => s.name === 'walletSucceeded')?.data as {
106
+ relayDropCount?: number
107
+ }
108
+ // Undefined on the bus → dropped by compactUndefined on the telemetry egress.
109
+ expect(end.relayDropCount).toBeUndefined()
110
+ })
111
+
50
112
  it('carries targetAlreadyActive through awaitingWallet and the terminal', async () => {
51
113
  const em = new EventManager()
52
114
  const seen = record(em)
@@ -15,6 +15,7 @@ import {
15
15
  } from '../utils/to-wallet-error'
16
16
  import { derivePlatform, deriveWalletFlowType } from './telemetry'
17
17
  import { trackFocusDuringHandoff } from './focus-tracker'
18
+ import { trackRelayDropsDuringHandoff } from './relay-drop-tracker'
18
19
 
19
20
  /** Routing facets known at the moment of a wallet handoff. No address, no payload. */
20
21
  export interface HandoffContext {
@@ -141,7 +142,32 @@ export async function instrumentHandoff<T>(
141
142
  // Observe page-visibility transitions ONLY while this handoff is pending — the
142
143
  // app-switch signal for deep-link flows (left for the wallet / came back). The
143
144
  // teardown in `finally` removes the listeners the instant the op settles.
144
- const stopFocus = trackFocusDuringHandoff(eventManager, handoffId, platform)
145
+ // Pass `now` through so backgroundedMs shares the same clock as durationMs
146
+ // (consistent, and deterministic under an injected clock in tests).
147
+ const focus = trackFocusDuringHandoff(
148
+ eventManager,
149
+ handoffId,
150
+ platform,
151
+ undefined,
152
+ undefined,
153
+ now
154
+ )
155
+
156
+ // Count relay-socket drops seen while this handoff is pending (WalletConnect
157
+ // only — the tracker is a no-op for other modes so a WC relay flap isn't
158
+ // misattributed to an injected/TON handoff in flight at the same time).
159
+ const relayDrops = trackRelayDropsDuringHandoff(
160
+ eventManager,
161
+ context.connectionMode
162
+ )
163
+
164
+ // Backgrounding + relay-drop summary, snapshotted onto each terminal so ONE
165
+ // event answers "backgrounded? how long? socket dropped? recovered?".
166
+ // `relayDropCount` is undefined off WalletConnect so `compactUndefined` drops it.
167
+ const handoffMetrics = () => ({
168
+ ...focus.summary(),
169
+ ...relayDrops.summary()
170
+ })
145
171
 
146
172
  // Same routing facets as `awaitingWallet` so every terminal is self-describing
147
173
  // (no handoffId self-join needed to know which wallet / chain / mode it closed).
@@ -164,7 +190,8 @@ export async function instrumentHandoff<T>(
164
190
  operation: context.operation,
165
191
  durationMs: now() - start,
166
192
  timeoutMs,
167
- ...routing
193
+ ...routing,
194
+ ...handoffMetrics()
168
195
  })
169
196
  }, timeoutMs)
170
197
  : undefined
@@ -177,7 +204,8 @@ export async function instrumentHandoff<T>(
177
204
  durationMs: now() - start,
178
205
  timedOut,
179
206
  timeoutMs: timedOut ? timeoutMs : undefined,
180
- ...routing
207
+ ...routing,
208
+ ...handoffMetrics()
181
209
  })
182
210
  return result
183
211
  } catch (error) {
@@ -207,7 +235,8 @@ export async function instrumentHandoff<T>(
207
235
  errorCode,
208
236
  failureKind,
209
237
  source,
210
- ...routing
238
+ ...routing,
239
+ ...handoffMetrics()
211
240
  })
212
241
  } else {
213
242
  eventManager.emit('walletFailed', {
@@ -220,12 +249,14 @@ export async function instrumentHandoff<T>(
220
249
  errorCode,
221
250
  failureKind,
222
251
  source,
223
- ...routing
252
+ ...routing,
253
+ ...handoffMetrics()
224
254
  })
225
255
  }
226
256
  throw error
227
257
  } finally {
228
258
  if (timer) clearTimeout(timer)
229
- stopFocus()
259
+ relayDrops.stop()
260
+ focus.stop()
230
261
  }
231
262
  }