@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,549 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest'
2
+ import type { Session } from '@meshconnect/uwc-types'
3
+ import {
4
+ normalizeEvent,
5
+ deriveWalletFlowType,
6
+ derivePlatform,
7
+ TELEMETRY_SCHEMA_VERSION
8
+ } from './telemetry'
9
+ import { UWC_CORE_VERSION } from '../version'
10
+
11
+ describe('deriveWalletFlowType', () => {
12
+ it('maps injected to extension on desktop, in-wallet-browser on mobile', () => {
13
+ expect(deriveWalletFlowType('injected', 'desktop')).toBe(
14
+ 'browser_extension_flow'
15
+ )
16
+ expect(deriveWalletFlowType('injected', 'mobile')).toBe(
17
+ 'in_wallet_browser_flow'
18
+ )
19
+ expect(deriveWalletFlowType('injected', 'webview')).toBe(
20
+ 'in_wallet_browser_flow'
21
+ )
22
+ })
23
+
24
+ it('maps walletConnect to QR on desktop, deeplink on mobile', () => {
25
+ expect(deriveWalletFlowType('walletConnect', 'desktop')).toBe(
26
+ 'wallet_connect_qr_flow'
27
+ )
28
+ expect(deriveWalletFlowType('walletConnect', 'mobile')).toBe(
29
+ 'wallet_connect_deep_link_flow'
30
+ )
31
+ })
32
+
33
+ it('maps tonConnect to QR on desktop, deeplink on mobile', () => {
34
+ expect(deriveWalletFlowType('tonConnect', 'desktop')).toBe(
35
+ 'ton_connect_qr_flow'
36
+ )
37
+ expect(deriveWalletFlowType('tonConnect', 'mobile')).toBe(
38
+ 'ton_connect_deep_link_flow'
39
+ )
40
+ })
41
+ })
42
+
43
+ describe('derivePlatform', () => {
44
+ afterEach(() => vi.unstubAllGlobals())
45
+
46
+ it('returns desktop for a desktop UA', () => {
47
+ vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh)' })
48
+ expect(derivePlatform()).toBe('desktop')
49
+ })
50
+
51
+ it('returns mobile for an iOS Safari UA', () => {
52
+ vi.stubGlobal('navigator', {
53
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Safari/604.1'
54
+ })
55
+ expect(derivePlatform()).toBe('mobile')
56
+ })
57
+
58
+ it('returns webview for an Android in-app browser UA (; wv)', () => {
59
+ vi.stubGlobal('navigator', {
60
+ userAgent: 'Mozilla/5.0 (Linux; Android 13; wv) Chrome/120'
61
+ })
62
+ expect(derivePlatform()).toBe('webview')
63
+ })
64
+ })
65
+
66
+ describe('normalizeEvent', () => {
67
+ const SDK_SESSION = 'sess-1'
68
+ const NOW = 1_700_000_000_000
69
+
70
+ it('reduces a `connected` session to facets and never leaks the address', () => {
71
+ const session = {
72
+ connectionMode: 'injected',
73
+ activeNetwork: { namespace: 'eip155', id: 'eip155:1', name: 'Ethereum' },
74
+ activeWallet: { id: 'metamask', name: 'MetaMask' },
75
+ activeAddress: '0xDEADBEEFcafe1234567890',
76
+ publicKey: 'pubkey-should-not-leak'
77
+ } as unknown as Session
78
+
79
+ const out = normalizeEvent('connected', { session }, SDK_SESSION, NOW)
80
+
81
+ expect(out.namespace).toBe('eip155')
82
+ expect(out.chainId).toBe('eip155:1')
83
+ expect(out.walletId).toBe('metamask')
84
+ expect(out.sdkSessionId).toBe(SDK_SESSION)
85
+ expect(out.timestamp).toBe(NOW)
86
+ // The address / pubkey must be structurally absent from the record.
87
+ const serialized = JSON.stringify(out)
88
+ expect(serialized).not.toContain('0xDEADBEEF')
89
+ expect(serialized).not.toContain('pubkey-should-not-leak')
90
+ })
91
+
92
+ it('surfaces missingNamespaces when a supported namespace returned no address', () => {
93
+ // The framed-embed secondary-gather skip: Phantom connects on EVM, supports
94
+ // Solana for a configured network, but no Solana address came back. This is
95
+ // the exact symptom that would otherwise be silent.
96
+ const session = {
97
+ connectionMode: 'injected',
98
+ activeNetwork: { namespace: 'eip155', id: 'eip155:1', name: 'Ethereum' },
99
+ activeWallet: { id: 'phantom', name: 'Phantom' },
100
+ activeAddress: '0xEVMonly1234567890',
101
+ publicKey: null,
102
+ availableNetworks: [
103
+ { namespace: 'eip155', id: 'eip155:1' },
104
+ { namespace: 'solana', id: 'solana:101' }
105
+ ],
106
+ availableAddresses: [
107
+ { networkId: 'eip155:1', address: '0xEVMonly1234567890' }
108
+ ]
109
+ } as unknown as Session
110
+
111
+ const out = normalizeEvent('connected', { session }, SDK_SESSION, NOW)
112
+
113
+ expect(out.addressNamespaces).toEqual({ eip155: 1 })
114
+ expect(out.missingNamespaces).toEqual(['solana'])
115
+ // Still only namespace names + counts — no address.
116
+ expect(JSON.stringify(out)).not.toContain('0xEVMonly')
117
+ })
118
+
119
+ it('counts addresses per namespace and omits missingNamespaces on full coverage', () => {
120
+ const session = {
121
+ connectionMode: 'injected',
122
+ activeNetwork: { namespace: 'eip155', id: 'eip155:1' },
123
+ activeWallet: { id: 'phantom' },
124
+ availableNetworks: [
125
+ { namespace: 'eip155', id: 'eip155:1' },
126
+ { namespace: 'eip155', id: 'eip155:8453' },
127
+ { namespace: 'solana', id: 'solana:101' }
128
+ ],
129
+ availableAddresses: [
130
+ { networkId: 'eip155:1', address: '0xa' },
131
+ { networkId: 'eip155:8453', address: '0xb' },
132
+ { networkId: 'solana:101', address: 'SoLaddr' }
133
+ ]
134
+ } as unknown as Session
135
+
136
+ // sessionChanged shares the path — assert the second name resolves too.
137
+ const out = normalizeEvent('sessionChanged', { session }, SDK_SESSION, NOW)
138
+
139
+ expect(out.addressNamespaces).toEqual({ eip155: 2, solana: 1 })
140
+ expect('missingNamespaces' in out).toBe(false)
141
+ })
142
+
143
+ it('scrubs the error message on `error` events', () => {
144
+ // '0' is outside the base58 alphabet, so this exercises the length cap
145
+ // (a base58-looking run of this length would be redacted whole instead).
146
+ const out = normalizeEvent(
147
+ 'error',
148
+ {
149
+ error: { type: 'unknown', message: '0'.repeat(800) },
150
+ operation: 'signMessage'
151
+ },
152
+ SDK_SESSION,
153
+ NOW
154
+ )
155
+ expect(out.operation).toBe('signMessage')
156
+ expect(out.error?.message.endsWith('…[truncated]')).toBe(true)
157
+ })
158
+
159
+ it('caps networkMismatch id lists on egress (wallet-declared, unbounded upstream)', () => {
160
+ // walletNetworkIds comes from the wallet's own supportedNetworkIds — a
161
+ // non-compliant wallet controls its cardinality and element length, so the
162
+ // telemetry copy is bounded. Elements are NOT scrubbed: a legit
163
+ // `solana:<32-char-base58-ref>` CAIP-2 id would be eaten by the base58 rule.
164
+ const walletNetworkIds = [
165
+ ...Array.from({ length: 29 }, (_, i) => `eip155:${i}`),
166
+ 'x'.repeat(200)
167
+ ]
168
+ const out = normalizeEvent(
169
+ 'error',
170
+ {
171
+ error: { type: 'unknown', message: 'no overlap' },
172
+ operation: 'connect',
173
+ networkMismatch: {
174
+ walletNetworkIds,
175
+ configuredNetworkIds: ['eip155:1']
176
+ }
177
+ },
178
+ SDK_SESSION,
179
+ NOW
180
+ )
181
+ expect(out.networkMismatch?.walletNetworkIds).toHaveLength(20)
182
+ expect(
183
+ out.networkMismatch?.walletNetworkIds.every(id => id.length <= 64)
184
+ ).toBe(true)
185
+ expect(out.networkMismatch?.configuredNetworkIds).toEqual(['eip155:1'])
186
+ })
187
+
188
+ it('omits undefined optional fields rather than emitting present-but-undefined keys', () => {
189
+ // An ordinary error carries no classification facets — they must be ABSENT,
190
+ // not present-and-undefined, so a consumer's `'errorCode' in event` is honest.
191
+ const out = normalizeEvent(
192
+ 'error',
193
+ {
194
+ error: { type: 'unknown', message: 'boom' },
195
+ operation: 'signMessage'
196
+ },
197
+ SDK_SESSION,
198
+ NOW
199
+ )
200
+ expect('errorCode' in out).toBe(false)
201
+ expect('failureKind' in out).toBe(false)
202
+ expect('source' in out).toBe(false)
203
+ expect('networkMismatch' in out).toBe(false)
204
+ expect(Object.values(out).every(v => v !== undefined)).toBe(true)
205
+ })
206
+
207
+ it('passes handoff facets through for awaitingWallet', () => {
208
+ const out = normalizeEvent(
209
+ 'awaitingWallet',
210
+ {
211
+ operation: 'sendTransaction',
212
+ connectionMode: 'walletConnect',
213
+ walletId: 'rainbow',
214
+ namespace: 'eip155',
215
+ chainId: 'eip155:8453',
216
+ handoffId: 'h-1',
217
+ walletFlowType: 'wallet_connect_deep_link_flow',
218
+ platform: 'mobile'
219
+ },
220
+ SDK_SESSION,
221
+ NOW
222
+ )
223
+ expect(out.handoffId).toBe('h-1')
224
+ expect(out.walletFlowType).toBe('wallet_connect_deep_link_flow')
225
+ expect(out.platform).toBe('mobile')
226
+ })
227
+
228
+ it('collapses noisy/unknown events to name only', () => {
229
+ const out = normalizeEvent('ready', undefined, SDK_SESSION, NOW)
230
+ expect(out).toEqual({
231
+ schemaVersion: TELEMETRY_SCHEMA_VERSION,
232
+ uwcVersion: UWC_CORE_VERSION,
233
+ name: 'ready',
234
+ sdkSessionId: SDK_SESSION,
235
+ timestamp: NOW
236
+ })
237
+ })
238
+
239
+ it('stamps schemaVersion on every record', () => {
240
+ const ready = normalizeEvent('ready', undefined, SDK_SESSION, NOW)
241
+ const connecting = normalizeEvent(
242
+ 'connecting',
243
+ { connectionMode: 'injected', walletId: 'metamask' },
244
+ SDK_SESSION,
245
+ NOW
246
+ )
247
+ expect(ready.schemaVersion).toBe(TELEMETRY_SCHEMA_VERSION)
248
+ expect(connecting.schemaVersion).toBe(TELEMETRY_SCHEMA_VERSION)
249
+ })
250
+
251
+ it('stamps uwcVersion on every record (aggregate bugs per released version)', () => {
252
+ const ready = normalizeEvent('ready', undefined, SDK_SESSION, NOW)
253
+ const connecting = normalizeEvent(
254
+ 'connecting',
255
+ { connectionMode: 'injected', walletId: 'metamask' },
256
+ SDK_SESSION,
257
+ NOW
258
+ )
259
+ expect(ready.uwcVersion).toBe(UWC_CORE_VERSION)
260
+ expect(connecting.uwcVersion).toBe(UWC_CORE_VERSION)
261
+ })
262
+
263
+ it('stamps correlationId as its own field only when supplied (never aliased)', () => {
264
+ const without = normalizeEvent('ready', undefined, SDK_SESSION, NOW)
265
+ expect(without.correlationId).toBeUndefined()
266
+ expect('correlationId' in without).toBe(false)
267
+
268
+ const withId = normalizeEvent(
269
+ 'ready',
270
+ undefined,
271
+ SDK_SESSION,
272
+ NOW,
273
+ 'link-123'
274
+ )
275
+ expect(withId.correlationId).toBe('link-123')
276
+ // The generated session id is untouched — the two are not aliased.
277
+ expect(withId.sdkSessionId).toBe(SDK_SESSION)
278
+ })
279
+
280
+ it('threads timedOut / timeoutMs on a late walletSucceeded', () => {
281
+ const out = normalizeEvent(
282
+ 'walletSucceeded',
283
+ {
284
+ handoffId: 'h-1',
285
+ operation: 'sendTransaction',
286
+ durationMs: 1234,
287
+ connectionMode: 'injected',
288
+ timedOut: true,
289
+ timeoutMs: 1000
290
+ },
291
+ SDK_SESSION,
292
+ NOW
293
+ )
294
+ expect(out.timedOut).toBe(true)
295
+ expect(out.timeoutMs).toBe(1000)
296
+ })
297
+
298
+ it('threads timeoutMs + targetAlreadyActive on walletTimedOut', () => {
299
+ const out = normalizeEvent(
300
+ 'walletTimedOut',
301
+ {
302
+ handoffId: 'h-1',
303
+ operation: 'switchNetwork',
304
+ durationMs: 1000,
305
+ connectionMode: 'injected',
306
+ timeoutMs: 1000,
307
+ targetAlreadyActive: true
308
+ },
309
+ SDK_SESSION,
310
+ NOW
311
+ )
312
+ expect(out.timeoutMs).toBe(1000)
313
+ expect(out.targetAlreadyActive).toBe(true)
314
+ })
315
+
316
+ it('threads errorCode / failureKind / source on walletRejected and walletFailed', () => {
317
+ const rejected = normalizeEvent(
318
+ 'walletRejected',
319
+ {
320
+ handoffId: 'h-1',
321
+ operation: 'signMessage',
322
+ durationMs: 5,
323
+ connectionMode: 'injected',
324
+ timedOut: false,
325
+ errorCode: '4001',
326
+ failureKind: 'user_rejected',
327
+ source: 'provider'
328
+ },
329
+ SDK_SESSION,
330
+ NOW
331
+ )
332
+ expect(rejected.errorCode).toBe('4001')
333
+ expect(rejected.failureKind).toBe('user_rejected')
334
+ expect(rejected.source).toBe('provider')
335
+
336
+ const failed = normalizeEvent(
337
+ 'walletFailed',
338
+ {
339
+ handoffId: 'h-1',
340
+ operation: 'signMessage',
341
+ durationMs: 5,
342
+ error: { type: 'unknown', message: 'boom' },
343
+ connectionMode: 'injected',
344
+ timedOut: false,
345
+ failureKind: 'unknown',
346
+ source: 'unknown'
347
+ },
348
+ SDK_SESSION,
349
+ NOW
350
+ )
351
+ expect(failed.failureKind).toBe('unknown')
352
+ expect(failed.source).toBe('unknown')
353
+ })
354
+
355
+ it('summarizes capabilitiesUpdated to per-chain supported feature names', () => {
356
+ const out = normalizeEvent(
357
+ 'capabilitiesUpdated',
358
+ {
359
+ capabilities: {
360
+ 'eip155:8453': {
361
+ atomic: { status: 'supported' },
362
+ paymasterService: { status: 'unsupported' }
363
+ },
364
+ 'eip155:1': { atomic: { status: 'ready' } }
365
+ }
366
+ },
367
+ SDK_SESSION,
368
+ NOW
369
+ )
370
+ expect(out.capabilities).toEqual({
371
+ 'eip155:8453': ['atomic'],
372
+ 'eip155:1': ['atomic']
373
+ })
374
+ })
375
+
376
+ it('threads taxonomy + networkMismatch on an `error` event (delta A)', () => {
377
+ const out = normalizeEvent(
378
+ 'error',
379
+ {
380
+ error: {
381
+ type: 'unknown',
382
+ message:
383
+ 'No common supported network found between wallet and configured networks'
384
+ },
385
+ operation: 'connect',
386
+ failureKind: 'no_common_network',
387
+ source: 'connector',
388
+ networkMismatch: {
389
+ walletNetworkIds: ['eip155:1', 'eip155:137'],
390
+ configuredNetworkIds: ['solana:101']
391
+ }
392
+ },
393
+ SDK_SESSION,
394
+ NOW
395
+ )
396
+ expect(out.operation).toBe('connect')
397
+ expect(out.failureKind).toBe('no_common_network')
398
+ expect(out.source).toBe('connector')
399
+ expect(out.networkMismatch).toEqual({
400
+ walletNetworkIds: ['eip155:1', 'eip155:137'],
401
+ configuredNetworkIds: ['solana:101']
402
+ })
403
+ })
404
+
405
+ it('carries the discovery summary on walletsDetected, never the wallet list', () => {
406
+ const out = normalizeEvent(
407
+ 'walletsDetected',
408
+ {
409
+ wallets: [{ id: 'leaky', name: 'Leaky' } as never],
410
+ discovery: { solana: { requested: 2, matched: 0 } }
411
+ },
412
+ SDK_SESSION,
413
+ NOW
414
+ )
415
+ expect(out.discovery).toEqual({ solana: { requested: 2, matched: 0 } })
416
+ // The raw wallet list must NOT ride along.
417
+ expect(JSON.stringify(out)).not.toContain('leaky')
418
+ })
419
+
420
+ it('normalizes walletDiscoveryFailed with scrubbed message + routing (P5)', () => {
421
+ const out = normalizeEvent(
422
+ 'walletDiscoveryFailed',
423
+ {
424
+ namespace: 'tvm',
425
+ connectionMode: 'tonConnect',
426
+ error: { type: 'unknown', message: 'bridge unreachable' }
427
+ },
428
+ SDK_SESSION,
429
+ NOW
430
+ )
431
+ expect(out.namespace).toBe('tvm')
432
+ expect(out.connectionMode).toBe('tonConnect')
433
+ expect(out.error?.message).toBe('bridge unreachable')
434
+ })
435
+
436
+ it('normalizes lifecycle events with routing only, no address (P2)', () => {
437
+ const acct = normalizeEvent(
438
+ 'walletAccountChanged',
439
+ { connectionMode: 'injected', walletId: 'metamask', namespace: 'eip155' },
440
+ SDK_SESSION,
441
+ NOW
442
+ )
443
+ expect(acct).toEqual({
444
+ schemaVersion: TELEMETRY_SCHEMA_VERSION,
445
+ uwcVersion: UWC_CORE_VERSION,
446
+ name: 'walletAccountChanged',
447
+ sdkSessionId: SDK_SESSION,
448
+ timestamp: NOW,
449
+ connectionMode: 'injected',
450
+ walletId: 'metamask',
451
+ namespace: 'eip155'
452
+ })
453
+
454
+ const chain = normalizeEvent(
455
+ 'walletChainChanged',
456
+ {
457
+ connectionMode: 'injected',
458
+ namespace: 'eip155',
459
+ chainId: 'eip155:8453'
460
+ },
461
+ SDK_SESSION,
462
+ NOW
463
+ )
464
+ expect(chain.chainId).toBe('eip155:8453')
465
+ })
466
+
467
+ it('normalizes lifecycleTelemetryUnavailable with reason + runtime (P2)', () => {
468
+ const out = normalizeEvent(
469
+ 'lifecycleTelemetryUnavailable',
470
+ {
471
+ reason: 'provider_subscription_unavailable',
472
+ connectionMode: 'walletConnect',
473
+ namespace: 'eip155',
474
+ runtime: 'iframe'
475
+ },
476
+ SDK_SESSION,
477
+ NOW
478
+ )
479
+ expect(out.reason).toBe('provider_subscription_unavailable')
480
+ expect(out.runtime).toBe('iframe')
481
+ })
482
+
483
+ it('normalizes bridgeError with source/failureKind + scrubbed message (P6)', () => {
484
+ const out = normalizeEvent(
485
+ 'bridgeError',
486
+ {
487
+ namespace: 'tron',
488
+ error: {
489
+ type: 'unknown',
490
+ message: 'discoverTronWallets is not a function'
491
+ }
492
+ },
493
+ SDK_SESSION,
494
+ NOW
495
+ )
496
+ expect(out.namespace).toBe('tron')
497
+ expect(out.source).toBe('bridge')
498
+ expect(out.failureKind).toBe('bridge_error')
499
+ expect(out.error?.message).toBe('discoverTronWallets is not a function')
500
+ })
501
+
502
+ it('normalizes relayError as a walletConnect network failure, message scrubbed', () => {
503
+ const out = normalizeEvent(
504
+ 'relayError',
505
+ {
506
+ error: {
507
+ type: 'unknown',
508
+ message:
509
+ 'WebSocket connection closed abnormally with code: 3000 (JWT validation error: JWT Token is not yet valid) 0xAbC1230000000000000000000000000000000000'
510
+ }
511
+ },
512
+ SDK_SESSION,
513
+ NOW
514
+ )
515
+ expect(out.connectionMode).toBe('walletConnect')
516
+ expect(out.source).toBe('provider')
517
+ expect(out.failureKind).toBe('network_error')
518
+ // The diagnostic close code survives; a stray address in the message does not.
519
+ expect(out.error?.message).toContain('3000')
520
+ expect(JSON.stringify(out)).not.toContain('0xAbC123')
521
+ })
522
+
523
+ it('carries handoffCovered on an error record that duplicates a funnel terminal', () => {
524
+ const out = normalizeEvent(
525
+ 'error',
526
+ {
527
+ error: { type: 'unknown', message: 'wallet exploded' },
528
+ operation: 'signMessage',
529
+ handoffCovered: true
530
+ },
531
+ SDK_SESSION,
532
+ NOW
533
+ )
534
+ expect(out.handoffCovered).toBe(true)
535
+ })
536
+
537
+ it('omits handoffCovered on a pre-wallet error record (no duplicate terminal)', () => {
538
+ const out = normalizeEvent(
539
+ 'error',
540
+ {
541
+ error: { type: 'unknown', message: 'No provider available' },
542
+ operation: 'signMessage'
543
+ },
544
+ SDK_SESSION,
545
+ NOW
546
+ )
547
+ expect('handoffCovered' in out).toBe(false)
548
+ })
549
+ })