@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,546 @@
1
+ import type {
2
+ ConnectionMode,
3
+ WalletError,
4
+ WalletFlowType,
5
+ Platform,
6
+ LogLevel,
7
+ Namespace,
8
+ NetworkId,
9
+ FailureKind,
10
+ ErrorSource,
11
+ LifecycleUnavailableReason,
12
+ Session
13
+ } from '@meshconnect/uwc-types'
14
+ import type { UWCEventName, UWCEventMap, UWCOperation } from '../events'
15
+ import { UWC_CORE_VERSION } from '../version'
16
+ import { scrubMessage } from './scrub'
17
+
18
+ /**
19
+ * Telemetry record schema version. Bump on any breaking change to the
20
+ * `UWCTelemetryEvent` shape so a downstream consumer can branch on it.
21
+ */
22
+ export const TELEMETRY_SCHEMA_VERSION = 1
23
+
24
+ /**
25
+ * Egress bounds for `networkMismatch`. `walletNetworkIds` comes from catalog
26
+ * wallet metadata (`provider.supportedNetworkIds`), not live wallet input — but
27
+ * it is unbounded upstream, so cap cardinality and element length here as
28
+ * defense-in-depth. Elements are NOT scrubbed: a legitimate
29
+ * `solana:<32-char-base58-ref>` CAIP-2 id would be eaten by the base58 rule.
30
+ */
31
+ const MAX_NETWORK_MISMATCH_IDS = 20
32
+ const MAX_NETWORK_ID_LENGTH = 64
33
+
34
+ function capNetworkMismatch(
35
+ mismatch:
36
+ | { walletNetworkIds: string[]; configuredNetworkIds: string[] }
37
+ | undefined
38
+ ): { walletNetworkIds: string[]; configuredNetworkIds: string[] } | undefined {
39
+ if (!mismatch) return undefined
40
+ const cap = (ids: string[]) =>
41
+ ids
42
+ .slice(0, MAX_NETWORK_MISMATCH_IDS)
43
+ .map(id => id.slice(0, MAX_NETWORK_ID_LENGTH))
44
+ return {
45
+ walletNetworkIds: cap(mismatch.walletNetworkIds),
46
+ configuredNetworkIds: cap(mismatch.configuredNetworkIds)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * A normalized, PII-safe telemetry record derived from a `UWCEventMap` event.
52
+ *
53
+ * This is the ONLY shape that reaches `observer.onEvent`. It is the PII boundary:
54
+ * it has no field for a signing payload, signature, raw transaction, or wallet
55
+ * address — leaking those is structurally impossible, not merely discouraged.
56
+ */
57
+ export interface UWCTelemetryEvent {
58
+ /**
59
+ * Record schema version — the literal type IS the version this package
60
+ * emits, so a consumer can branch exhaustively (`event.schemaVersion === 1`)
61
+ * and gets a compile break when a bump lands. Import the runtime value as
62
+ * `TELEMETRY_SCHEMA_VERSION`.
63
+ */
64
+ schemaVersion: typeof TELEMETRY_SCHEMA_VERSION
65
+ /**
66
+ * The `@meshconnect/uwc-core` version that produced the record (generated
67
+ * from package.json at build time) — aggregate failures per released
68
+ * version to spot version-specific regressions.
69
+ */
70
+ uwcVersion: string
71
+ /** The originating event name. */
72
+ name: UWCEventName
73
+ /** Per-connector-instance id (stamped on every event). */
74
+ sdkSessionId: string
75
+ /**
76
+ * Consumer-supplied correlation id (e.g. their `link_sess_id`), present only
77
+ * when supplied — never aliased to `sdkSessionId`. Lets UWC events join the
78
+ * consumer's existing telemetry without a separate mapping.
79
+ */
80
+ correlationId?: string | undefined
81
+ /** ms since epoch when the record was produced. */
82
+ timestamp: number
83
+ /** Wallet-boundary op, when the event relates to one. */
84
+ operation?: UWCOperation | undefined
85
+ connectionMode?: ConnectionMode | undefined
86
+ /** CAIP namespace (eip155 | solana | tron | tvm …) — never an address. */
87
+ namespace?: Namespace | undefined
88
+ /** CAIP chain id (e.g. `eip155:1`) — never an address. */
89
+ chainId?: NetworkId | undefined
90
+ /** Catalog wallet id — never an address. */
91
+ walletId?: string | undefined
92
+ /** Correlates a handoff start with its terminal. */
93
+ handoffId?: string | undefined
94
+ durationMs?: number | undefined
95
+ walletFlowType?: WalletFlowType | undefined
96
+ platform?: Platform | undefined
97
+ /** A `walletTimedOut` marker fired before the terminal (late completion). */
98
+ timedOut?: boolean | undefined
99
+ /** Marker deadline — present on `walletTimedOut` and on a late terminal. */
100
+ timeoutMs?: number | undefined
101
+ /** switchNetwork only: target chain was already the active one (no-op switch). */
102
+ targetAlreadyActive?: boolean | undefined
103
+ /** Non-success outcomes: stringified error code when reliably known. */
104
+ errorCode?: string | undefined
105
+ /** Non-success outcomes: coarse cause bucket. `unknown` when not classifiable. */
106
+ failureKind?: FailureKind | undefined
107
+ /** Non-success outcomes: where the error originated, when inferable. */
108
+ source?: ErrorSource | undefined
109
+ /**
110
+ * Connect `no_common_network` failures only: the wallet's advertised vs the
111
+ * configured CAIP-2 id lists (non-PII). Lets a dashboard show "wallet offered
112
+ * X, token configured Y" — the diagnosis for the #1 prod connection error.
113
+ */
114
+ networkMismatch?:
115
+ | { walletNetworkIds: string[]; configuredNetworkIds: string[] }
116
+ | undefined
117
+ /** Present on failure events. `message` is scrubbed before egress. */
118
+ error?: WalletError | undefined
119
+ /** Log level, present only for `name === 'log'`. */
120
+ level?: LogLevel | undefined
121
+ /**
122
+ * Per-chain supported capability names (e.g. `{ 'eip155:8453': ['atomic'] }`),
123
+ * present only for `capabilitiesUpdated`. Feature flags + chain ids — not PII.
124
+ */
125
+ capabilities?: Record<string, string[]> | undefined
126
+ /**
127
+ * Per-namespace requested-vs-matched discovery summary, present only on
128
+ * `walletsDetected`. Counts only — never a wallet id or address.
129
+ */
130
+ discovery?: Record<string, { requested: number; matched: number }> | undefined
131
+ /**
132
+ * Per-namespace count of the session's available addresses, present on
133
+ * `connected` / `sessionChanged`. Counts only — never an address.
134
+ */
135
+ addressNamespaces?: Record<string, number> | undefined
136
+ /**
137
+ * Namespaces the connected wallet supports for this session's configured
138
+ * networks but returned NO address for — present on `connected` /
139
+ * `sessionChanged` only when non-empty. A RAW FACT (supported-but-absent), not
140
+ * an asserted bug: it's the observable symptom of a silently dropped
141
+ * secondary-namespace gather — e.g. injected-connector's framed-embed skip,
142
+ * where the wallet supports Solana but the session has zero Solana addresses.
143
+ * The consumer interprets it (link-v2 deposit ⇒ lost holdings; a single-
144
+ * namespace deposit ⇒ intended). Pairs with `discovery` for the full picture.
145
+ */
146
+ missingNamespaces?: string[] | undefined
147
+ /** `lifecycleTelemetryUnavailable` only: why lifecycle telemetry couldn't attach. */
148
+ reason?: LifecycleUnavailableReason | undefined
149
+ /** `lifecycleTelemetryUnavailable` only: direct page vs production iframe. */
150
+ runtime?: 'direct' | 'iframe' | undefined
151
+ /**
152
+ * `error` only: a handoff terminal already reported this failure — this
153
+ * record is the back-compat duplicate. Count terminals + `error` where NOT
154
+ * handoffCovered to avoid double-counting.
155
+ */
156
+ handoffCovered?: boolean | undefined
157
+ }
158
+
159
+ /**
160
+ * Vendor-agnostic telemetry sink. Consumers implement; UWC ships no vendor SDK.
161
+ * Both methods are optional and called best-effort (throws are swallowed upstream).
162
+ *
163
+ * CONTRACT — both methods are invoked **synchronously on the wallet-operation
164
+ * path**. A terminal event (e.g. `walletSucceeded`) fires between the wallet
165
+ * returning a result and that result reaching the caller, so a slow handler adds
166
+ * latency to connect / sign / sendTransaction. Keep handlers **non-blocking**:
167
+ * hand off to a queue-backed sink (Datadog `addAction`, Segment `track`, etc. are
168
+ * already non-blocking) or defer heavy work yourself — e.g.
169
+ * `onEvent: e => queueMicrotask(() => doHeavyWork(e))`. Do not perform synchronous
170
+ * network/IO or expensive serialization inline.
171
+ */
172
+ export interface UWCObserver {
173
+ /** A normalized, PII-safe event. Forward to Datadog / Amplitude / Segment / … (non-blocking). */
174
+ onEvent?(event: UWCTelemetryEvent): void
175
+ /** A log line (all levels). `args` is already scrubbed. Keep non-blocking. */
176
+ onLog?(level: LogLevel, message: string, args: unknown[]): void
177
+ }
178
+
179
+ /**
180
+ * Best-effort coarse runtime surface. HEURISTIC — user agents lie and in-app
181
+ * browsers are hard to detect; treat `webview` as a hint, not a guarantee.
182
+ * Never returns the raw UA (PII / high-cardinality).
183
+ */
184
+ export function derivePlatform(): Platform {
185
+ if (typeof navigator === 'undefined' || !navigator.userAgent) return 'unknown'
186
+ const ua = navigator.userAgent
187
+ const isIOS = /iPhone|iPad|iPod/i.test(ua)
188
+ const isAndroid = /Android/i.test(ua)
189
+ // Android in-app browsers tag the UA with `; wv`; iOS WKWebViews lack `Safari`.
190
+ const isWebview =
191
+ (isAndroid && /; wv\)/i.test(ua)) || (isIOS && !/Safari/i.test(ua))
192
+ if (isWebview) return 'webview'
193
+ if (isIOS || isAndroid) return 'mobile'
194
+ return 'desktop'
195
+ }
196
+
197
+ /**
198
+ * Derive the flow taxonomy from connection mode + surface. HEURISTIC — UWC does
199
+ * not always know whether a WalletConnect handoff used a deeplink vs a QR scan;
200
+ * we infer from the platform (mobile ⇒ deeplink, desktop ⇒ QR), matching link-v2's
201
+ * historical intent. Consumers with better context may override downstream.
202
+ */
203
+ export function deriveWalletFlowType(
204
+ connectionMode: ConnectionMode,
205
+ platform: Platform
206
+ ): WalletFlowType {
207
+ const isDesktop = platform === 'desktop'
208
+ switch (connectionMode) {
209
+ case 'injected':
210
+ // Injected on a phone almost always means we're inside a wallet's browser.
211
+ return isDesktop ? 'browser_extension_flow' : 'in_wallet_browser_flow'
212
+ case 'walletConnect':
213
+ return isDesktop
214
+ ? 'wallet_connect_qr_flow'
215
+ : 'wallet_connect_deep_link_flow'
216
+ case 'tonConnect':
217
+ return isDesktop ? 'ton_connect_qr_flow' : 'ton_connect_deep_link_flow'
218
+ default:
219
+ return 'unknown'
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Per-namespace address coverage for a session — telemetry-only, non-PII
225
+ * (namespace names + counts, never an address). Derived purely from the session:
226
+ * `availableNetworks` is the connected wallet's supported networks already
227
+ * filtered to the session's configured set, and `availableAddresses` is what the
228
+ * connect actually gathered, so `missing` is precise per-connect (no cross-wallet
229
+ * discovery join that could false-positive on a different installed wallet).
230
+ *
231
+ * `missing` is the observable symptom of a dropped secondary-namespace gather:
232
+ * the wallet supports the namespace for a configured network, but no address for
233
+ * it came back.
234
+ */
235
+ function summarizeAddressCoverage(session: Session): {
236
+ addressNamespaces: Record<string, number>
237
+ missing: string[]
238
+ } {
239
+ // Defensive `?? []`: this runs on the wallet path, so a partial/malformed
240
+ // session must never throw here (a real session always has both arrays).
241
+ const addressNamespaces: Record<string, number> = {}
242
+ const present = new Set<string>()
243
+ for (const { networkId } of session.availableAddresses ?? []) {
244
+ const namespace = networkId.split(':')[0]
245
+ if (!namespace) continue
246
+ present.add(namespace)
247
+ addressNamespaces[namespace] = (addressNamespaces[namespace] ?? 0) + 1
248
+ }
249
+ const supported = new Set(
250
+ (session.availableNetworks ?? []).map(n => n.namespace)
251
+ )
252
+ const missing = [...supported].filter(namespace => !present.has(namespace))
253
+ return { addressNamespaces, missing }
254
+ }
255
+
256
+ /**
257
+ * Map a raw `UWCEventMap` event to its PII-safe `UWCTelemetryEvent`.
258
+ *
259
+ * Per-event allow-listing: only known-safe fields are copied. Payloads carrying a
260
+ * `Session`/`Network` (which include the wallet address) are reduced to
261
+ * `namespace` + `chainId` + `walletId`. Unknown/noisy payloads collapse to just
262
+ * the name. `error.message` is scrubbed.
263
+ */
264
+ function buildTelemetryEvent<K extends UWCEventName>(
265
+ name: K,
266
+ data: UWCEventMap[K],
267
+ sdkSessionId: string,
268
+ now: number,
269
+ correlationId?: string
270
+ ): UWCTelemetryEvent {
271
+ // `correlationId` is added to the key only when supplied — never aliased to
272
+ // `sdkSessionId`, so a consumer can tell "I gave no id" from "id == session".
273
+ const base: UWCTelemetryEvent = {
274
+ schemaVersion: TELEMETRY_SCHEMA_VERSION,
275
+ uwcVersion: UWC_CORE_VERSION,
276
+ name,
277
+ sdkSessionId,
278
+ timestamp: now,
279
+ ...(correlationId !== undefined ? { correlationId } : {})
280
+ }
281
+
282
+ switch (name) {
283
+ case 'connecting': {
284
+ const d = data as UWCEventMap['connecting']
285
+ return {
286
+ ...base,
287
+ operation: 'connect',
288
+ connectionMode: d.connectionMode,
289
+ walletId: d.walletId
290
+ }
291
+ }
292
+ case 'connected':
293
+ case 'sessionChanged': {
294
+ const d = data as UWCEventMap['connected']
295
+ const session = d.session
296
+ const { addressNamespaces, missing } = summarizeAddressCoverage(session)
297
+ return {
298
+ ...base,
299
+ connectionMode: session.connectionMode ?? undefined,
300
+ namespace: session.activeNetwork?.namespace,
301
+ chainId: session.activeNetwork?.id,
302
+ walletId: session.activeWallet?.id,
303
+ addressNamespaces,
304
+ ...(missing.length > 0 && { missingNamespaces: missing })
305
+ }
306
+ }
307
+ case 'networkSwitched': {
308
+ const d = data as UWCEventMap['networkSwitched']
309
+ return {
310
+ ...base,
311
+ operation: 'switchNetwork',
312
+ namespace: d.network.namespace,
313
+ chainId: d.network.id
314
+ }
315
+ }
316
+ case 'connectionUri': {
317
+ // Carry the mode but NEVER the URI itself (a relay/pairing string).
318
+ const d = data as UWCEventMap['connectionUri']
319
+ return { ...base, connectionMode: d.connectionMode }
320
+ }
321
+ case 'error': {
322
+ const d = data as UWCEventMap['error']
323
+ return {
324
+ ...base,
325
+ operation: d.operation,
326
+ error: { type: d.error.type, message: scrubMessage(d.error.message) },
327
+ // Telemetry-only classification — undefined for ordinary errors, set for
328
+ // pre-wallet validation failures (e.g. no_common_network) that never
329
+ // reach the handoff funnel.
330
+ errorCode: d.errorCode,
331
+ failureKind: d.failureKind,
332
+ source: d.source,
333
+ networkMismatch: capNetworkMismatch(d.networkMismatch),
334
+ handoffCovered: d.handoffCovered
335
+ }
336
+ }
337
+ case 'awaitingWallet': {
338
+ const d = data as UWCEventMap['awaitingWallet']
339
+ return {
340
+ ...base,
341
+ operation: d.operation,
342
+ connectionMode: d.connectionMode,
343
+ walletId: d.walletId,
344
+ namespace: d.namespace,
345
+ chainId: d.chainId,
346
+ handoffId: d.handoffId,
347
+ walletFlowType: d.walletFlowType,
348
+ platform: d.platform,
349
+ targetAlreadyActive: d.targetAlreadyActive
350
+ }
351
+ }
352
+ case 'walletSucceeded': {
353
+ const d = data as UWCEventMap['walletSucceeded']
354
+ return {
355
+ ...base,
356
+ operation: d.operation,
357
+ connectionMode: d.connectionMode,
358
+ walletId: d.walletId,
359
+ namespace: d.namespace,
360
+ chainId: d.chainId,
361
+ handoffId: d.handoffId,
362
+ durationMs: d.durationMs,
363
+ timedOut: d.timedOut,
364
+ timeoutMs: d.timeoutMs,
365
+ targetAlreadyActive: d.targetAlreadyActive
366
+ }
367
+ }
368
+ case 'walletRejected': {
369
+ const d = data as UWCEventMap['walletRejected']
370
+ return {
371
+ ...base,
372
+ operation: d.operation,
373
+ connectionMode: d.connectionMode,
374
+ walletId: d.walletId,
375
+ namespace: d.namespace,
376
+ chainId: d.chainId,
377
+ handoffId: d.handoffId,
378
+ durationMs: d.durationMs,
379
+ timedOut: d.timedOut,
380
+ timeoutMs: d.timeoutMs,
381
+ targetAlreadyActive: d.targetAlreadyActive,
382
+ errorCode: d.errorCode,
383
+ failureKind: d.failureKind,
384
+ source: d.source
385
+ }
386
+ }
387
+ case 'walletTimedOut': {
388
+ const d = data as UWCEventMap['walletTimedOut']
389
+ return {
390
+ ...base,
391
+ operation: d.operation,
392
+ connectionMode: d.connectionMode,
393
+ walletId: d.walletId,
394
+ namespace: d.namespace,
395
+ chainId: d.chainId,
396
+ handoffId: d.handoffId,
397
+ durationMs: d.durationMs,
398
+ timeoutMs: d.timeoutMs,
399
+ targetAlreadyActive: d.targetAlreadyActive
400
+ }
401
+ }
402
+ case 'walletFailed': {
403
+ const d = data as UWCEventMap['walletFailed']
404
+ return {
405
+ ...base,
406
+ operation: d.operation,
407
+ connectionMode: d.connectionMode,
408
+ walletId: d.walletId,
409
+ namespace: d.namespace,
410
+ chainId: d.chainId,
411
+ handoffId: d.handoffId,
412
+ durationMs: d.durationMs,
413
+ timedOut: d.timedOut,
414
+ timeoutMs: d.timeoutMs,
415
+ targetAlreadyActive: d.targetAlreadyActive,
416
+ errorCode: d.errorCode,
417
+ failureKind: d.failureKind,
418
+ source: d.source,
419
+ error: { type: d.error.type, message: scrubMessage(d.error.message) }
420
+ }
421
+ }
422
+ case 'capabilitiesUpdated': {
423
+ const d = data as UWCEventMap['capabilitiesUpdated']
424
+ // Summarize to per-chain lists of the capability names that are not
425
+ // 'unsupported'. Compact + non-PII (chain ids + bounded feature names).
426
+ const capabilities: Record<string, string[]> = {}
427
+ for (const [chainId, caps] of Object.entries(d.capabilities)) {
428
+ const enabled = Object.entries(caps)
429
+ .filter(([, cap]) => cap && cap.status !== 'unsupported')
430
+ .map(([feature]) => feature)
431
+ if (enabled.length > 0) capabilities[chainId] = enabled
432
+ }
433
+ return { ...base, capabilities }
434
+ }
435
+ case 'walletsDetected': {
436
+ const d = data as UWCEventMap['walletsDetected']
437
+ // Carry only the compact requested-vs-matched summary; never the wallet list.
438
+ return { ...base, ...(d.discovery ? { discovery: d.discovery } : {}) }
439
+ }
440
+ case 'walletDiscoveryFailed': {
441
+ const d = data as UWCEventMap['walletDiscoveryFailed']
442
+ return {
443
+ ...base,
444
+ connectionMode: d.connectionMode,
445
+ namespace: d.namespace,
446
+ error: { type: d.error.type, message: scrubMessage(d.error.message) }
447
+ }
448
+ }
449
+ case 'pageHiddenDuringHandoff':
450
+ case 'pageVisibleDuringHandoff': {
451
+ const d = data as UWCEventMap['pageHiddenDuringHandoff']
452
+ return { ...base, handoffId: d.handoffId, platform: d.platform }
453
+ }
454
+ case 'bridgeError': {
455
+ const d = data as UWCEventMap['bridgeError']
456
+ return {
457
+ ...base,
458
+ namespace: d.namespace,
459
+ source: 'bridge',
460
+ failureKind: 'bridge_error',
461
+ error: { type: d.error.type, message: scrubMessage(d.error.message) }
462
+ }
463
+ }
464
+ case 'relayError': {
465
+ const d = data as UWCEventMap['relayError']
466
+ // A relay-socket transport failure (WalletConnect only). The scrubbed message
467
+ // carries the diagnostic detail (e.g. "code: 3000 (JWT ... not yet valid)").
468
+ return {
469
+ ...base,
470
+ connectionMode: 'walletConnect',
471
+ source: 'provider',
472
+ failureKind: 'network_error',
473
+ error: { type: d.error.type, message: scrubMessage(d.error.message) }
474
+ }
475
+ }
476
+ case 'walletAccountChanged':
477
+ case 'walletDisconnected':
478
+ case 'walletSessionExpired': {
479
+ const d = data as UWCEventMap['walletAccountChanged']
480
+ return {
481
+ ...base,
482
+ connectionMode: d.connectionMode,
483
+ walletId: d.walletId,
484
+ namespace: d.namespace
485
+ }
486
+ }
487
+ case 'walletChainChanged': {
488
+ const d = data as UWCEventMap['walletChainChanged']
489
+ return {
490
+ ...base,
491
+ connectionMode: d.connectionMode,
492
+ walletId: d.walletId,
493
+ namespace: d.namespace,
494
+ chainId: d.chainId
495
+ }
496
+ }
497
+ case 'lifecycleTelemetryUnavailable': {
498
+ const d = data as UWCEventMap['lifecycleTelemetryUnavailable']
499
+ return {
500
+ ...base,
501
+ connectionMode: d.connectionMode,
502
+ walletId: d.walletId,
503
+ namespace: d.namespace,
504
+ reason: d.reason,
505
+ runtime: d.runtime
506
+ }
507
+ }
508
+ default:
509
+ // ready / disconnected / networkSwitching / change / log — name only
510
+ // (log goes via onLog).
511
+ return base
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Drop keys whose value is `undefined` so a record never carries a
517
+ * present-but-undefined optional (e.g. `errorCode: undefined` on a non-error
518
+ * event), which would make `'errorCode' in event` misleading. Shallow by design:
519
+ * nested objects (`error`, `networkMismatch`, `discovery`, `capabilities`,
520
+ * `addressNamespaces`) are only ever built with defined values.
521
+ */
522
+ function compactUndefined(event: UWCTelemetryEvent): UWCTelemetryEvent {
523
+ const record = event as unknown as Record<string, unknown>
524
+ const out: Record<string, unknown> = {}
525
+ for (const key of Object.keys(record)) {
526
+ const value = record[key]
527
+ if (value !== undefined) out[key] = value
528
+ }
529
+ return out as unknown as UWCTelemetryEvent
530
+ }
531
+
532
+ /**
533
+ * Normalize a bus event into a PII-safe `UWCTelemetryEvent`, dropping undefined
534
+ * optionals so the emitted shape is consistent across event kinds.
535
+ */
536
+ export function normalizeEvent<K extends UWCEventName>(
537
+ name: K,
538
+ data: UWCEventMap[K],
539
+ sdkSessionId: string,
540
+ now: number,
541
+ correlationId?: string
542
+ ): UWCTelemetryEvent {
543
+ return compactUndefined(
544
+ buildTelemetryEvent(name, data, sdkSessionId, now, correlationId)
545
+ )
546
+ }