@meshconnect/uwc-core 1.10.4 → 2.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 (51) hide show
  1. package/dist/events.d.ts +46 -2
  2. package/dist/events.d.ts.map +1 -1
  3. package/dist/events.js +2 -0
  4. package/dist/events.js.map +1 -1
  5. package/dist/observability/instrument-handoff.d.ts +8 -1
  6. package/dist/observability/instrument-handoff.d.ts.map +1 -1
  7. package/dist/observability/instrument-handoff.js +9 -2
  8. package/dist/observability/instrument-handoff.js.map +1 -1
  9. package/dist/observability/lifecycle-bridge.d.ts +31 -13
  10. package/dist/observability/lifecycle-bridge.d.ts.map +1 -1
  11. package/dist/observability/lifecycle-bridge.js +194 -92
  12. package/dist/observability/lifecycle-bridge.js.map +1 -1
  13. package/dist/observability/telemetry.d.ts +15 -1
  14. package/dist/observability/telemetry.d.ts.map +1 -1
  15. package/dist/observability/telemetry.js +15 -0
  16. package/dist/observability/telemetry.js.map +1 -1
  17. package/dist/services/connection-service.d.ts +109 -0
  18. package/dist/services/connection-service.d.ts.map +1 -1
  19. package/dist/services/connection-service.js +407 -22
  20. package/dist/services/connection-service.js.map +1 -1
  21. package/dist/services/network-switch-service.d.ts.map +1 -1
  22. package/dist/services/network-switch-service.js +4 -1
  23. package/dist/services/network-switch-service.js.map +1 -1
  24. package/dist/services/signature-service.d.ts +4 -3
  25. package/dist/services/signature-service.d.ts.map +1 -1
  26. package/dist/services/signature-service.js +2 -1
  27. package/dist/services/signature-service.js.map +1 -1
  28. package/dist/universal-wallet-connector.d.ts +15 -3
  29. package/dist/universal-wallet-connector.d.ts.map +1 -1
  30. package/dist/universal-wallet-connector.js +31 -2
  31. package/dist/universal-wallet-connector.js.map +1 -1
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.d.ts.map +1 -1
  34. package/dist/version.js +1 -1
  35. package/dist/version.js.map +1 -1
  36. package/package.json +5 -5
  37. package/src/events.ts +51 -3
  38. package/src/observability/instrument-handoff.ts +8 -2
  39. package/src/observability/lifecycle-bridge.test.ts +316 -0
  40. package/src/observability/lifecycle-bridge.ts +240 -99
  41. package/src/observability/telemetry.ts +32 -1
  42. package/src/services/connection-service.reentrancy.test.ts +87 -0
  43. package/src/services/connection-service.test.ts +798 -1
  44. package/src/services/connection-service.ts +492 -39
  45. package/src/services/network-switch-service.test.ts +33 -0
  46. package/src/services/network-switch-service.ts +4 -1
  47. package/src/services/signature-service.test.ts +9 -2
  48. package/src/services/signature-service.ts +4 -3
  49. package/src/universal-wallet-connector.observability.test.ts +126 -3
  50. package/src/universal-wallet-connector.ts +43 -4
  51. package/src/version.ts +1 -1
@@ -29,11 +29,23 @@ 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.
32
+ * bridge emits `lifecycleTelemetryUnavailable` and becomes a no-op FOR THIS
33
+ * STREAM — `subscribeAccounts` below is attached independently.
33
34
  */
34
35
  subscribe:
35
36
  | ((l: (e: WalletLifecycleEvent) => void) => (() => void) | undefined)
36
37
  | 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
37
49
  /** Resolved per-event so a mid-session account/chain change is reflected. */
38
50
  getRouting: () => LifecycleRouting
39
51
  connectionMode: ConnectionMode
@@ -42,8 +54,9 @@ export interface AttachLifecycleOptions {
42
54
  /** Direct page vs production iframe — stamped on `lifecycleTelemetryUnavailable`. */
43
55
  runtime?: 'direct' | 'iframe'
44
56
  /**
45
- * Window (ms) within which a wallet `chainChanged` is treated as the echo of a
46
- * UWC-initiated `switchNetwork` and suppressed. Default 3000.
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.
47
60
  */
48
61
  echoWindowMs?: number
49
62
  /**
@@ -53,29 +66,37 @@ export interface AttachLifecycleOptions {
53
66
  * provider's echo would otherwise surface as wallet-initiated. Same one-shot
54
67
  * consume semantics as a bus-armed switch.
55
68
  */
56
- initialSwitch?: { chainId: NetworkId; at: number } | undefined
69
+ initialSwitch?:
70
+ | { chainId: NetworkId; address?: string; at: number }
71
+ | undefined
57
72
  now?: () => number
58
73
  }
59
74
 
60
75
  /**
61
76
  * Bridge a connector's PII-safe `WalletLifecycleEvent` stream onto the event bus
62
77
  * as `walletAccountChanged` / `walletChainChanged` / `walletDisconnected` /
63
- * `walletSessionExpired`.
78
+ * `walletSessionExpired`, AND (separately) forward the connector's real
79
+ * account list to `onAccountsChanged` for session-sync.
64
80
  *
65
- * Two correctness concerns are handled here:
81
+ * Three correctness concerns are handled here:
66
82
  * - **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".
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.
74
95
  *
75
- * @returns a teardown that removes the connector subscription AND the bus watcher.
96
+ * @returns a teardown that removes both connector subscriptions AND the bus watcher.
76
97
  */
77
98
  export function attachLifecycle(opts: AttachLifecycleOptions): () => void {
78
- const { eventManager, subscribe, getRouting } = opts
99
+ const { eventManager, subscribe, subscribeAccounts, getRouting } = opts
79
100
  const runtime = opts.runtime ?? 'direct'
80
101
  const now = opts.now ?? Date.now
81
102
  const echoWindowMs = opts.echoWindowMs ?? 3000
@@ -89,11 +110,11 @@ export function attachLifecycle(opts: AttachLifecycleOptions): () => void {
89
110
  runtime
90
111
  })
91
112
 
92
- if (!subscribe) {
113
+ if (!subscribe && !subscribeAccounts) {
93
114
  // In the production iframe the injected subscription is deliberately not
94
115
  // 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.
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.
97
118
  emitUnavailable(
98
119
  runtime === 'iframe'
99
120
  ? 'bridge_subscription_unavailable'
@@ -102,115 +123,235 @@ export function attachLifecycle(opts: AttachLifecycleOptions): () => void {
102
123
  return () => {}
103
124
  }
104
125
 
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
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
+ // Address equality for echo suppression is case-insensitive on EVM: a
152
+ // UWC-initiated `switchNetwork` echo and the wallet's `accountsChanged` can
153
+ // differ in casing (checksummed vs lowercased), and a missed match would
154
+ // double-count the echo as a wallet-initiated account change. Solana / TRON /
155
+ // TON addresses are case-sensitive, so only fold case for eip155.
156
+ const normAddr = (addr: string | undefined): string | undefined =>
157
+ addr !== undefined && opts.namespace === 'eip155'
158
+ ? addr.toLowerCase()
159
+ : addr
160
+ // `awaitingWallet` then `networkSwitched` are usually the START and
161
+ // COMPLETION of the SAME switch — re-arming updates that existing entry
162
+ // (refreshing `at`, adding the resolved address) rather than pushing a
163
+ // duplicate, which would otherwise leave a phantom entry behind after only
164
+ // one of the two is consumed by a single wallet echo.
165
+ const armOrRearm = (partial: {
166
+ chainId?: NetworkId | undefined
167
+ address?: string | undefined
168
+ }) => {
169
+ pruneExpired()
170
+ if (partial.chainId !== undefined) {
171
+ const idx = pendingSwitches.findIndex(p => p.chainId === partial.chainId)
172
+ if (idx !== -1) {
173
+ pendingSwitches[idx] = {
174
+ ...pendingSwitches[idx]!,
175
+ ...partial,
176
+ at: now()
177
+ }
178
+ return
179
+ }
180
+ }
181
+ pendingSwitches.push({ ...partial, at: now() })
182
+ }
115
183
  const offAwaiting = eventManager.on('awaitingWallet', data => {
116
184
  if (data.operation === 'switchNetwork' && data.chainId !== undefined) {
117
- lastSwitch = { chainId: data.chainId, at: now() }
185
+ armOrRearm({ chainId: data.chainId })
118
186
  }
119
187
  })
120
188
  const offSwitched = eventManager.on('networkSwitched', data => {
121
- lastSwitch = { chainId: data.network.id, at: now() }
189
+ armOrRearm({ chainId: data.network.id, address: data.address })
122
190
  })
123
191
  const detachBusWatchers = () => {
124
192
  offAwaiting()
125
193
  offSwitched()
126
194
  }
127
195
 
196
+ // Emission budget for THIS attachment — telemetry ONLY. Account-sync
197
+ // (`onAccountsChanged`) is a BEHAVIOUR path (it keeps the live signer fresh),
198
+ // so it must never be silenced by a telemetry ceiling: a wallet that re-fires
199
+ // `accountsChanged` on focus would otherwise burn the budget and re-serve a
200
+ // stale signer — the exact bug this attachment closes. It's deduped instead
201
+ // (see `lastAccountSignal`). Suppressed echoes don't spend the budget.
202
+ let emitted = 0
203
+ // Last account signal actually reflected into the session (normalized primary,
204
+ // or '' for a disconnect). Dedupes an unchanged re-fire so account-sync stays
205
+ // uncapped without churning `updateSession` on every focus event.
206
+ let lastAccountSignal: string | undefined = undefined
207
+ let capNotified = false
208
+ const overCap = (): boolean => {
209
+ if (emitted >= MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT) {
210
+ // Mark the trip ONCE — a silent cap would be indistinguishable from a
211
+ // provider that simply went quiet.
212
+ if (!capNotified) {
213
+ capNotified = true
214
+ emitUnavailable('emission_cap')
215
+ }
216
+ return true
217
+ }
218
+ return false
219
+ }
220
+
128
221
  // Defense-in-depth: a connector whose subscribe THROWS (e.g. a provider whose
129
222
  // `.on` rejects an un-proxied handler across the iframe bridge) must degrade to
130
223
  // "unavailable", never break the connect path that called us.
131
224
  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')
225
+ if (subscribe) {
226
+ try {
227
+ unsub = subscribe(e => {
228
+ // The whole body is guarded: wallet SDKs dispatch their listeners
229
+ // unguarded (e.g. TonConnect's bare `forEach`), so a throw here would
230
+ // abort delivery to the wallet's OWN later subscribers, including one
231
+ // resolving a pending connect. Telemetry must never do that.
232
+ try {
233
+ if (overCap()) return
234
+ const routing = getRouting()
235
+ switch (e.type) {
236
+ case 'accountChanged':
237
+ emitted++
238
+ eventManager.emit('walletAccountChanged', routing)
239
+ return
240
+ case 'chainChanged': {
241
+ pruneExpired()
242
+ const idx =
243
+ e.chainId !== undefined
244
+ ? pendingSwitches.findIndex(
245
+ p => p.chainId !== undefined && p.chainId === e.chainId
246
+ )
247
+ : -1
248
+ if (idx !== -1) {
249
+ // Consume the echo: a UWC switch produces exactly one wallet echo, so
250
+ // a LATER chainChanged to the same chain inside the window is a genuine
251
+ // user-initiated switch-back and must not be suppressed. Only the
252
+ // chain half is consumed — a still-pending address echo (if the
253
+ // switch also changed address format) must still be checked; drop the
254
+ // entry entirely once nothing is left pending on it.
255
+ const entry = pendingSwitches[idx]!
256
+ if (entry.address !== undefined) {
257
+ pendingSwitches[idx] = { ...entry, chainId: undefined }
258
+ } else {
259
+ pendingSwitches.splice(idx, 1)
260
+ }
261
+ return
262
+ }
263
+ emitted++
264
+ eventManager.emit('walletChainChanged', {
265
+ ...routing,
266
+ ...(e.chainId !== undefined ? { chainId: e.chainId } : {})
267
+ })
268
+ return
269
+ }
270
+ case 'disconnect':
271
+ emitted++
272
+ eventManager.emit('walletDisconnected', routing)
273
+ return
274
+ case 'sessionExpired':
275
+ emitted++
276
+ eventManager.emit('walletSessionExpired', routing)
277
+ return
149
278
  }
150
- return
279
+ } catch {
280
+ // Swallowed: better to drop one lifecycle record than to break the
281
+ // wallet's event dispatch.
151
282
  }
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
283
+ })
284
+ } catch {
285
+ unsub = undefined
286
+ }
287
+ if (!unsub) {
288
+ // The connector accepted the subscribe call but this session has no
289
+ // observable telemetry surface (returned undefined or threw). Over the
290
+ // bridge, that absence is the bridge's doing, not the provider's.
291
+ emitUnavailable(
292
+ runtime === 'iframe'
293
+ ? 'bridge_subscription_unavailable'
294
+ : 'provider_subscription_unavailable'
295
+ )
296
+ }
297
+ }
298
+
299
+ let unsubAccounts: (() => void) | undefined
300
+ if (subscribeAccounts) {
301
+ try {
302
+ unsubAccounts = subscribeAccounts(accounts => {
303
+ try {
304
+ // NOT gated by the telemetry cap — account-sync is a behaviour path
305
+ // and must always follow a real switch (see `lastAccountSignal`).
306
+ const primary = accounts[0]
307
+ const primaryNorm = normAddr(primary)
308
+ const signal = accounts.length === 0 ? '' : (primaryNorm ?? '')
309
+ pruneExpired()
310
+ const idx =
311
+ accounts.length > 0
312
+ ? pendingSwitches.findIndex(
313
+ p =>
314
+ p.address !== undefined &&
315
+ normAddr(p.address) === primaryNorm
316
+ )
317
+ : -1
318
+ if (idx !== -1) {
319
+ // Same one-shot consume as the chainChanged echo above, mirrored
320
+ // for the address half. The session already reflects this address
321
+ // (the UWC switch set it), so record the signal to dedupe a later
322
+ // identical re-fire, then swallow the echo.
323
+ const entry = pendingSwitches[idx]!
324
+ if (entry.chainId !== undefined) {
325
+ pendingSwitches[idx] = { ...entry, address: undefined }
326
+ } else {
327
+ pendingSwitches.splice(idx, 1)
170
328
  }
171
- emitted++
172
- eventManager.emit('walletChainChanged', {
173
- ...routing,
174
- ...(e.chainId !== undefined ? { chainId: e.chainId } : {})
175
- })
329
+ lastAccountSignal = signal
176
330
  return
177
331
  }
178
- case 'disconnect':
179
- emitted++
180
- eventManager.emit('walletDisconnected', routing)
181
- return
182
- case 'sessionExpired':
183
- emitted++
184
- eventManager.emit('walletSessionExpired', routing)
185
- return
332
+ // Dedupe an unchanged re-fire (e.g. the wallet re-emitting the same
333
+ // account on window focus) so we don't re-write the session for a
334
+ // non-change. A genuine A→B→A still propagates each leg.
335
+ if (signal === lastAccountSignal) return
336
+ lastAccountSignal = signal
337
+ opts.onAccountsChanged?.(accounts)
338
+ } catch {
339
+ // Swallowed: never break the wallet SDK's own listener dispatch.
186
340
  }
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
341
+ })
342
+ } catch {
343
+ unsubAccounts = undefined
344
+ }
194
345
  }
195
346
 
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) {
347
+ if (!unsub && !unsubAccounts) {
200
348
  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
349
  return () => {}
210
350
  }
211
351
 
212
352
  return () => {
213
353
  detachBusWatchers()
214
- unsub()
354
+ unsub?.()
355
+ unsubAccounts?.()
215
356
  }
216
357
  }
@@ -9,7 +9,9 @@ import type {
9
9
  FailureKind,
10
10
  ErrorSource,
11
11
  LifecycleUnavailableReason,
12
- Session
12
+ Session,
13
+ WalletStepName,
14
+ WalletStepOutcome
13
15
  } from '@meshconnect/uwc-types'
14
16
  import type { UWCEventName, UWCEventMap, UWCOperation } from '../events'
15
17
  import { UWC_CORE_VERSION } from '../version'
@@ -184,6 +186,20 @@ export interface UWCTelemetryEvent {
184
186
  * handoffCovered to avoid double-counting.
185
187
  */
186
188
  handoffCovered?: boolean | undefined
189
+ /**
190
+ * `walletStep` only: which phase INSIDE the operation this record closes
191
+ * (provider detection, the authorizing wallet call, a secondary-namespace
192
+ * gather …). Pair with `durationMs` to see where a slow connect spent its
193
+ * time; group by `stepOutcome` to separate a hang from a failure.
194
+ */
195
+ step?: WalletStepName | undefined
196
+ /**
197
+ * `walletStep` only: how that phase ended. Its OWN field rather than reusing
198
+ * the terminal vocabulary — a `timedOut` step is fully compatible with a
199
+ * `walletSucceeded` handoff (a best-effort gather was dropped, the connect
200
+ * still succeeded), so conflating the two would misreport success as failure.
201
+ */
202
+ stepOutcome?: WalletStepOutcome | undefined
187
203
  /** `pageVisibleDuringHandoff` only: how long the page was hidden for that segment (ms). */
188
204
  hiddenMs?: number | undefined
189
205
  /** Handoff terminals: cumulative time the page was backgrounded during the handoff (ms). */
@@ -475,6 +491,21 @@ function buildTelemetryEvent<K extends UWCEventName>(
475
491
  relayDropCount: d.relayDropCount
476
492
  }
477
493
  }
494
+ case 'walletStep': {
495
+ const d = data as UWCEventMap['walletStep']
496
+ return {
497
+ ...base,
498
+ operation: d.operation,
499
+ connectionMode: d.connectionMode,
500
+ walletId: d.walletId,
501
+ namespace: d.namespace,
502
+ handoffId: d.handoffId,
503
+ durationMs: d.durationMs,
504
+ step: d.step,
505
+ stepOutcome: d.outcome,
506
+ timeoutMs: d.timeoutMs
507
+ }
508
+ }
478
509
  case 'walletFailed': {
479
510
  const d = data as UWCEventMap['walletFailed']
480
511
  return {
@@ -435,3 +435,90 @@ describe('MFS-602: ConnectionService.connect() re-entrancy safety', () => {
435
435
  expect(hidden).toHaveLength(1)
436
436
  })
437
437
  })
438
+
439
+ // ONC-183 review (P2a): the per-attempt handoff slot that step telemetry joins
440
+ // on must survive a supersession. Attempt A rejects via its abort listener, and
441
+ // that rejection's continuation runs in a MICROTASK — after re-entrant attempt
442
+ // B has already synchronously published its own handoff. An unconditional
443
+ // `finally` clear in A therefore wiped B's live value, leaving a still-pending
444
+ // B whose `walletStep` records carried no handoffId and could not be joined to
445
+ // their own terminal.
446
+ describe('ONC-183: handoff slot survives supersession', () => {
447
+ let svc: ConnectionService
448
+ let network: Network
449
+ let wallets: WalletMetadata[]
450
+ let injected: Connector
451
+
452
+ beforeEach(() => {
453
+ network = {
454
+ id: 'eip155:1',
455
+ internalId: 1,
456
+ name: 'Ethereum',
457
+ namespace: 'eip155',
458
+ networkType: 'evm',
459
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }
460
+ } as unknown as Network
461
+
462
+ wallets = [
463
+ {
464
+ id: 'metamask',
465
+ name: 'MetaMask',
466
+ metadata: {},
467
+ extensionInjectedProvider: { supportedNetworkIds: ['eip155:1'] }
468
+ } as unknown as WalletMetadata
469
+ ]
470
+
471
+ // Never settles — models a wallet prompt left open, so the only way an
472
+ // attempt ends is supersession.
473
+ injected = {
474
+ connect: vi.fn().mockReturnValue(new Promise(() => {})),
475
+ disconnect: vi.fn(),
476
+ switchNetwork: vi.fn()
477
+ } as unknown as Connector
478
+
479
+ const map = new Map<ConnectionMode, Connector>()
480
+ map.set('injected', injected)
481
+ svc = new ConnectionService(
482
+ [network],
483
+ wallets,
484
+ new SessionManager(),
485
+ map,
486
+ false,
487
+ new EventManager(),
488
+ 0
489
+ )
490
+ })
491
+
492
+ it('a superseded attempt does not clear the newer attempt’s handoff', async () => {
493
+ svc.connect('injected', 'metamask').catch(() => {})
494
+ await Promise.resolve()
495
+ const a = svc.getCurrentHandoff()
496
+ expect(a?.handoffId).toBeDefined()
497
+
498
+ // B supersedes A. Both are pending.
499
+ svc.connect('injected', 'metamask').catch(() => {})
500
+ const b = svc.getCurrentHandoff()
501
+ expect(b?.handoffId).toBeDefined()
502
+ expect(b?.handoffId).not.toBe(a?.handoffId)
503
+
504
+ // Drain A's abort-rejection continuation (where the bad clear lived).
505
+ await Promise.resolve()
506
+ await Promise.resolve()
507
+ await Promise.resolve()
508
+
509
+ // B is STILL pending, so its handoff must still be readable and unchanged.
510
+ expect(svc.getCurrentHandoff()).toEqual(b)
511
+ })
512
+
513
+ it('clears the slot once the last attempt actually settles', async () => {
514
+ svc.connect('injected', 'metamask').catch(() => {})
515
+ await Promise.resolve()
516
+ expect(svc.getCurrentHandoff()).toBeDefined()
517
+
518
+ // disconnect() aborts the in-flight attempt — the genuine settle path.
519
+ await svc.disconnect().catch(() => {})
520
+ await Promise.resolve()
521
+
522
+ expect(svc.getCurrentHandoff()).toBeUndefined()
523
+ })
524
+ })