@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,610 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import type {
3
+ Network,
4
+ WalletMetadata,
5
+ Logger,
6
+ Session
7
+ } from '@meshconnect/uwc-types'
8
+ import { NoCommonNetworkError } from '@meshconnect/uwc-types'
9
+ import { UniversalWalletConnector } from './universal-wallet-connector'
10
+ import type { UWCObserver, UWCTelemetryEvent } from './observability/telemetry'
11
+
12
+ // Mock connectors (and the connect/switch services) so the façade resolves
13
+ // deterministically, but leave the EventManager + logger + observability wiring
14
+ // AND the sign/send services REAL — sign/send here run end-to-end through the
15
+ // real SignatureService / TransactionService (which own validation + the
16
+ // funnel) down to the mocked connector boundary.
17
+ const signMessageMock = vi.fn()
18
+ const signTypedDataMock = vi.fn()
19
+ const tonSignMessageMock = vi.fn()
20
+ // Shared so individual tests can drive the connect path (delta A) and the
21
+ // detection path (P5) without re-mocking the module.
22
+ const connectMock = vi.fn()
23
+ const getAvailableWalletsMock = vi.fn()
24
+ const teardownLifecycleMock = vi.fn()
25
+ const reattachLifecycleMock = vi.fn()
26
+ const switchNetworkMock = vi.fn()
27
+ const sendTransactionMock = vi.fn()
28
+ const getWalletCapabilitiesMock = vi.fn()
29
+
30
+ const connectedSession: Session = {
31
+ connectionMode: 'injected',
32
+ activeNetwork: {
33
+ internalId: 1,
34
+ id: 'eip155:1',
35
+ namespace: 'eip155',
36
+ name: 'Ethereum',
37
+ logoUrl: '',
38
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
39
+ networkType: 'evm'
40
+ },
41
+ activeWalletCapabilities: null,
42
+ activeNetworkWalletCapabilities: null,
43
+ activeAddress: '0xabc',
44
+ publicKey: null,
45
+ availableNetworks: [],
46
+ activeWallet: {
47
+ id: 'metamask',
48
+ name: 'MetaMask',
49
+ extensionInjectedProvider: {}
50
+ } as unknown as WalletMetadata,
51
+ availableAddresses: []
52
+ }
53
+
54
+ vi.mock('@meshconnect/uwc-injected-connector', () => ({
55
+ InjectedConnector: vi.fn().mockImplementation(function () {
56
+ return {
57
+ getAvailableWallets: getAvailableWalletsMock,
58
+ // The REAL sign/send services route connectionMode 'injected' here —
59
+ // these mocks are the instrumented wallet boundary.
60
+ signMessage: signMessageMock,
61
+ signTypedData: signTypedDataMock,
62
+ sendTransaction: sendTransactionMock
63
+ }
64
+ })
65
+ }))
66
+ vi.mock('@meshconnect/uwc-wallet-connect-connector', () => ({
67
+ WalletConnectConnector: vi.fn().mockImplementation(function () {
68
+ return { getAvailableWallets: vi.fn().mockResolvedValue([]) }
69
+ })
70
+ }))
71
+ vi.mock('@meshconnect/uwc-ton-connector', () => ({
72
+ TonConnectConnector: vi.fn().mockImplementation(function () {
73
+ return {
74
+ getAvailableWallets: vi.fn().mockResolvedValue([]),
75
+ signMessage: tonSignMessageMock
76
+ }
77
+ })
78
+ }))
79
+ vi.mock('./managers/session-manager', () => ({
80
+ SessionManager: vi.fn().mockImplementation(function () {
81
+ return { getSession: () => connectedSession, updateSession: vi.fn() }
82
+ })
83
+ }))
84
+ vi.mock('./services/connection-service', () => ({
85
+ ConnectionService: vi.fn().mockImplementation(function () {
86
+ return {
87
+ connect: connectMock,
88
+ disconnect: vi.fn().mockResolvedValue(undefined),
89
+ getWallets: vi.fn().mockReturnValue([]),
90
+ getNetworks: vi.fn().mockReturnValue([]),
91
+ getConnectionURI: vi.fn(),
92
+ updateWallets: vi.fn(),
93
+ teardownLifecycle: teardownLifecycleMock,
94
+ reattachLifecycle: reattachLifecycleMock
95
+ }
96
+ })
97
+ }))
98
+ vi.mock('./services/network-switch-service', () => ({
99
+ NetworkSwitchService: vi.fn().mockImplementation(function () {
100
+ return { switchNetwork: switchNetworkMock, getLoadingState: vi.fn() }
101
+ })
102
+ }))
103
+ vi.mock('./services/wallet-capabilities-service', () => ({
104
+ WalletCapabilitiesService: vi.fn().mockImplementation(function () {
105
+ return { getWalletCapabilities: getWalletCapabilitiesMock }
106
+ })
107
+ }))
108
+
109
+ const networks = [
110
+ {
111
+ internalId: 1,
112
+ id: 'eip155:1',
113
+ namespace: 'eip155',
114
+ name: 'Ethereum',
115
+ logoUrl: '',
116
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
117
+ networkType: 'evm'
118
+ }
119
+ ] as unknown as Network[]
120
+
121
+ const wallets = [
122
+ { id: 'metamask', name: 'MetaMask' }
123
+ ] as unknown as WalletMetadata[]
124
+
125
+ describe('UniversalWalletConnector — observability wiring', () => {
126
+ beforeEach(() => {
127
+ UniversalWalletConnector.resetInstance()
128
+ signMessageMock.mockReset()
129
+ signMessageMock.mockResolvedValue({ type: 'standard', signature: '0xsig' })
130
+ connectMock.mockReset()
131
+ connectMock.mockResolvedValue(undefined)
132
+ getAvailableWalletsMock.mockReset()
133
+ getAvailableWalletsMock.mockResolvedValue([])
134
+ teardownLifecycleMock.mockReset()
135
+ reattachLifecycleMock.mockReset()
136
+ switchNetworkMock.mockReset()
137
+ switchNetworkMock.mockResolvedValue(undefined)
138
+ sendTransactionMock.mockReset()
139
+ sendTransactionMock.mockResolvedValue({ hash: '0xtx' })
140
+ signTypedDataMock.mockReset()
141
+ signTypedDataMock.mockResolvedValue('0xtypedsig')
142
+ tonSignMessageMock.mockReset()
143
+ tonSignMessageMock.mockResolvedValue({
144
+ type: 'standard',
145
+ signature: 'tonsig'
146
+ })
147
+ getWalletCapabilitiesMock.mockReset()
148
+ getWalletCapabilitiesMock.mockResolvedValue(undefined)
149
+ })
150
+
151
+ it('forwards a sign handoff to observer.onEvent (awaitingWallet → walletSucceeded)', async () => {
152
+ const events: UWCTelemetryEvent[] = []
153
+ const observer: UWCObserver = { onEvent: e => events.push(e) }
154
+ const uwc = new UniversalWalletConnector({ networks, wallets, observer })
155
+
156
+ await uwc.signMessage('hello')
157
+
158
+ const names = events.map(e => e.name)
159
+ expect(names).toContain('awaitingWallet')
160
+ expect(names).toContain('walletSucceeded')
161
+
162
+ const awaiting = events.find(e => e.name === 'awaitingWallet')!
163
+ const succeeded = events.find(e => e.name === 'walletSucceeded')!
164
+ // Same correlation id pairs start↔terminal, and the per-instance session id
165
+ // is stamped on every record.
166
+ expect(awaiting.handoffId).toBe(succeeded.handoffId)
167
+ expect(awaiting.sdkSessionId).toBe(succeeded.sdkSessionId)
168
+ expect(awaiting.operation).toBe('signMessage')
169
+ expect(signMessageMock).toHaveBeenCalledOnce()
170
+ })
171
+
172
+ it('stamps a consumer-supplied correlationId (string) as its own field', async () => {
173
+ const events: UWCTelemetryEvent[] = []
174
+ const uwc = new UniversalWalletConnector({
175
+ networks,
176
+ wallets,
177
+ observer: { onEvent: e => events.push(e) },
178
+ correlationId: 'link-123'
179
+ })
180
+
181
+ await uwc.signMessage('hello')
182
+
183
+ expect(events.length).toBeGreaterThan(0)
184
+ // correlationId is the consumer's value; sdkSessionId stays the generated
185
+ // instance id and is never aliased to it.
186
+ expect(events.every(e => e.correlationId === 'link-123')).toBe(true)
187
+ expect(events.every(e => e.sdkSessionId !== 'link-123')).toBe(true)
188
+ expect(
189
+ events.every(
190
+ e => typeof e.sdkSessionId === 'string' && e.sdkSessionId.length > 0
191
+ )
192
+ ).toBe(true)
193
+ })
194
+
195
+ it('resolves a correlationId getter per-event (dynamic id)', async () => {
196
+ let id = 'a'
197
+ const events: UWCTelemetryEvent[] = []
198
+ const uwc = new UniversalWalletConnector({
199
+ networks,
200
+ wallets,
201
+ observer: { onEvent: e => events.push(e) },
202
+ correlationId: () => id
203
+ })
204
+
205
+ await uwc.signMessage('one')
206
+ id = 'b'
207
+ await uwc.signMessage('two')
208
+
209
+ const ids = new Set(events.map(e => e.correlationId))
210
+ expect(ids.has('a')).toBe(true)
211
+ expect(ids.has('b')).toBe(true)
212
+ })
213
+
214
+ it('omits correlationId (keeps the instance id) when the getter throws', async () => {
215
+ const events: UWCTelemetryEvent[] = []
216
+ const uwc = new UniversalWalletConnector({
217
+ networks,
218
+ wallets,
219
+ observer: { onEvent: e => events.push(e) },
220
+ correlationId: () => {
221
+ throw new Error('not ready')
222
+ }
223
+ })
224
+
225
+ await uwc.signMessage('hello')
226
+
227
+ expect(events.length).toBeGreaterThan(0)
228
+ // No consumer id survives a throw — the field is omitted...
229
+ expect(events.every(e => e.correlationId === undefined)).toBe(true)
230
+ // ...but the generated instance id is always stamped.
231
+ expect(
232
+ events.every(
233
+ e => typeof e.sdkSessionId === 'string' && e.sdkSessionId.length > 0
234
+ )
235
+ ).toBe(true)
236
+ })
237
+
238
+ it('emits walletFailed when the underlying op throws', async () => {
239
+ signMessageMock.mockRejectedValueOnce(new Error('relay down'))
240
+ const events: UWCTelemetryEvent[] = []
241
+ const uwc = new UniversalWalletConnector({
242
+ networks,
243
+ wallets,
244
+ observer: { onEvent: e => events.push(e) }
245
+ })
246
+
247
+ await expect(uwc.signMessage('hello')).rejects.toThrow('relay down')
248
+ const failed = events.find(e => e.name === 'walletFailed')
249
+ expect(failed?.error?.message).toBe('relay down')
250
+ // The back-compat `error` event still fires too — marked as the duplicate
251
+ // of the terminal so metrics can count without double-counting.
252
+ const err = events.find(e => e.name === 'error')
253
+ expect(err).toBeDefined()
254
+ expect(err?.handoffCovered).toBe(true)
255
+ })
256
+
257
+ it('marks the error record of a walletRejected terminal as handoffCovered', async () => {
258
+ signMessageMock.mockRejectedValueOnce({ code: 4001 })
259
+ const events: UWCTelemetryEvent[] = []
260
+ const uwc = new UniversalWalletConnector({
261
+ networks,
262
+ wallets,
263
+ observer: { onEvent: e => events.push(e) }
264
+ })
265
+
266
+ await expect(uwc.signMessage('hello')).rejects.toEqual({ code: 4001 })
267
+
268
+ expect(events.some(e => e.name === 'walletRejected')).toBe(true)
269
+ const err = events.find(e => e.name === 'error')
270
+ expect(err?.handoffCovered).toBe(true)
271
+ expect(err?.failureKind).toBe('user_rejected')
272
+ })
273
+
274
+ it('an aborted op emits awaitingWallet with no terminal and no error', async () => {
275
+ // A DOMException AbortError is an intentional cancellation, not a wallet
276
+ // outcome: instrumentHandoff emits no terminal, and emitError must skip it
277
+ // too (both paths share isAbortError).
278
+ signMessageMock.mockRejectedValueOnce(
279
+ new DOMException('Aborted', 'AbortError')
280
+ )
281
+ const events: UWCTelemetryEvent[] = []
282
+ const uwc = new UniversalWalletConnector({
283
+ networks,
284
+ wallets,
285
+ observer: { onEvent: e => events.push(e) }
286
+ })
287
+
288
+ await expect(uwc.signMessage('hello')).rejects.toThrow()
289
+
290
+ const names = events.map(e => e.name)
291
+ expect(names).toContain('awaitingWallet')
292
+ expect(names).not.toContain('walletSucceeded')
293
+ expect(names).not.toContain('walletRejected')
294
+ expect(names).not.toContain('walletFailed')
295
+ expect(names).not.toContain('error')
296
+ })
297
+
298
+ it('a custom abort(reason) mid-flight emits NO terminal and NO error (the signal is the evidence)', async () => {
299
+ // `controller.abort(new Error(...))` rejects with the CUSTOM reason — not an
300
+ // AbortError by name — so both the funnel and emitError must consult the
301
+ // caller's signal, not just the error name.
302
+ const controller = new AbortController()
303
+ const reason = new Error('nav away')
304
+ signMessageMock.mockImplementationOnce(async () => {
305
+ controller.abort(reason)
306
+ throw reason
307
+ })
308
+ const events: UWCTelemetryEvent[] = []
309
+ const uwc = new UniversalWalletConnector({
310
+ networks,
311
+ wallets,
312
+ observer: { onEvent: e => events.push(e) }
313
+ })
314
+
315
+ await expect(
316
+ uwc.signMessage('hello', { signal: controller.signal })
317
+ ).rejects.toThrow('nav away')
318
+
319
+ const names = events.map(e => e.name)
320
+ expect(names).toContain('awaitingWallet')
321
+ expect(names).not.toContain('walletFailed')
322
+ expect(names).not.toContain('walletRejected')
323
+ expect(names).not.toContain('error')
324
+ })
325
+
326
+ it('an abort racing completion records the truthful walletSucceeded, then rejects with the abort', async () => {
327
+ const controller = new AbortController()
328
+ signMessageMock.mockImplementationOnce(async () => {
329
+ controller.abort()
330
+ return { type: 'standard', signature: '0xsig' }
331
+ })
332
+ const events: UWCTelemetryEvent[] = []
333
+ const uwc = new UniversalWalletConnector({
334
+ networks,
335
+ wallets,
336
+ observer: { onEvent: e => events.push(e) }
337
+ })
338
+
339
+ await expect(
340
+ uwc.signMessage('hello', { signal: controller.signal })
341
+ ).rejects.toMatchObject({ name: 'AbortError' })
342
+
343
+ const names = events.map(e => e.name)
344
+ expect(names).toContain('walletSucceeded')
345
+ expect(names).not.toContain('error')
346
+ })
347
+
348
+ it('a custom abort(reason) during getWalletCapabilities emits NO error (same contract as the other ops)', async () => {
349
+ // getWalletCapabilities accepts { signal } like the wallet ops — its
350
+ // emitError must consult the same signal, or a custom abort(reason)
351
+ // surfaces as a spurious `error` only for this op.
352
+ const controller = new AbortController()
353
+ const reason = new Error('nav away')
354
+ getWalletCapabilitiesMock.mockImplementationOnce(async () => {
355
+ controller.abort(reason)
356
+ throw reason
357
+ })
358
+ const events: UWCTelemetryEvent[] = []
359
+ const uwc = new UniversalWalletConnector({
360
+ networks,
361
+ wallets,
362
+ observer: { onEvent: e => events.push(e) }
363
+ })
364
+
365
+ await expect(
366
+ uwc.getWalletCapabilities('0xabc', undefined, {
367
+ signal: controller.signal
368
+ })
369
+ ).rejects.toThrow('nav away')
370
+
371
+ expect(events.map(e => e.name)).not.toContain('error')
372
+ })
373
+
374
+ it('routes a custom logger as the sink and the multi-instance warning through it', () => {
375
+ const calls: string[] = []
376
+ const logger: Logger = {
377
+ debug: () => {},
378
+ info: () => {},
379
+ warn: () => {},
380
+ error: msg => calls.push(msg)
381
+ }
382
+ // First instance is fine; the SECOND triggers the multi-instance warning,
383
+ // which must travel through the injected logger, not raw console.
384
+ new UniversalWalletConnector({ networks, wallets, logger })
385
+ new UniversalWalletConnector({ networks, wallets, logger })
386
+
387
+ expect(calls.some(m => m.includes('Multiple instances'))).toBe(true)
388
+ })
389
+
390
+ it('enriches the `error` event with no_common_network + networkMismatch (delta A)', async () => {
391
+ // The #1 prod connection failure is a PRE-wallet validation throw, so it
392
+ // surfaces via the `error` event (no handoff terminal). emitError must still
393
+ // classify it and forward the two CAIP-2 id lists.
394
+ connectMock.mockRejectedValueOnce(
395
+ new NoCommonNetworkError(['eip155:1', 'eip155:137'], ['solana:101'])
396
+ )
397
+ const events: UWCTelemetryEvent[] = []
398
+ const uwc = new UniversalWalletConnector({
399
+ networks,
400
+ wallets,
401
+ observer: { onEvent: e => events.push(e) }
402
+ })
403
+
404
+ await expect(uwc.connect('injected', 'metamask')).rejects.toThrow()
405
+
406
+ const err = events.find(e => e.name === 'error')!
407
+ expect(err.operation).toBe('connect')
408
+ expect(err.failureKind).toBe('no_common_network')
409
+ expect(err.source).toBe('connector')
410
+ expect(err.networkMismatch).toEqual({
411
+ walletNetworkIds: ['eip155:1', 'eip155:137'],
412
+ configuredNetworkIds: ['solana:101']
413
+ })
414
+ // Pre-wallet failure: no terminal fired, so this record is not a duplicate.
415
+ expect(err.handoffCovered).toBeUndefined()
416
+ })
417
+
418
+ it('emits walletDiscoveryFailed + a requested-vs-matched summary (P5)', async () => {
419
+ // Detection runs in the constructor. A connector throw used to vanish in a
420
+ // silent catch; now it surfaces, and the summary distinguishes "asked for 1,
421
+ // matched 0" from "asked for none".
422
+ getAvailableWalletsMock.mockRejectedValue(new Error('eip6963 probe failed'))
423
+ const eip155Wallet = {
424
+ id: 'metamask',
425
+ name: 'MetaMask',
426
+ extensionInjectedProvider: {
427
+ supportedNetworkIds: ['eip155:1'],
428
+ namespaceMetaData: {
429
+ eip155: { eip155Name: 'metamask', injectedId: 'isMetaMask' }
430
+ }
431
+ }
432
+ } as unknown as WalletMetadata
433
+
434
+ const events: UWCTelemetryEvent[] = []
435
+ let resolveReady!: () => void
436
+ const ready = new Promise<void>(r => (resolveReady = r))
437
+ new UniversalWalletConnector({
438
+ networks,
439
+ wallets: [eip155Wallet],
440
+ observer: {
441
+ onEvent: e => {
442
+ events.push(e)
443
+ if (e.name === 'ready') resolveReady()
444
+ }
445
+ }
446
+ })
447
+ await ready
448
+
449
+ const failed = events.find(e => e.name === 'walletDiscoveryFailed')!
450
+ expect(failed.namespace).toBe('eip155')
451
+ expect(failed.connectionMode).toBe('injected')
452
+ expect(failed.error?.message).toBe('eip6963 probe failed')
453
+
454
+ const detected = events.find(e => e.name === 'walletsDetected')!
455
+ expect(detected.discovery).toEqual({ eip155: { requested: 1, matched: 0 } })
456
+ })
457
+
458
+ it('a missing provider fails BEFORE the funnel — config error, not a wallet outcome', async () => {
459
+ // Design parity with connect/switch: provider resolution is validation, so
460
+ // its failure must surface only via the `error` event, never as an
461
+ // awaitingWallet + walletFailed pair (which would pollute the funnel with a
462
+ // failure the wallet never saw).
463
+ const originalWallet = connectedSession.activeWallet
464
+ connectedSession.activeWallet = {
465
+ id: 'metamask',
466
+ name: 'MetaMask'
467
+ // no extensionInjectedProvider / walletConnectProvider
468
+ } as unknown as WalletMetadata
469
+ const events: UWCTelemetryEvent[] = []
470
+ const uwc = new UniversalWalletConnector({
471
+ networks,
472
+ wallets,
473
+ observer: { onEvent: e => events.push(e) }
474
+ })
475
+
476
+ try {
477
+ await expect(uwc.signMessage('hello')).rejects.toThrow(
478
+ 'No provider available for the active wallet'
479
+ )
480
+ await expect(
481
+ uwc.sendTransaction({ to: '0xdef', amount: 1n } as never)
482
+ ).rejects.toThrow('No provider available for the active wallet')
483
+ await expect(
484
+ uwc.signTypedData({
485
+ domain: {},
486
+ types: {},
487
+ primaryType: 'X',
488
+ message: {}
489
+ } as never)
490
+ ).rejects.toThrow('No provider available for the active wallet')
491
+
492
+ const names = events.map(e => e.name)
493
+ expect(names).not.toContain('awaitingWallet')
494
+ expect(names).not.toContain('walletFailed')
495
+ const errs = events.filter(e => e.name === 'error')
496
+ expect(errs).toHaveLength(3)
497
+ // No terminal fired, so none of these are duplicates.
498
+ expect(errs.every(e => e.handoffCovered === undefined)).toBe(true)
499
+ expect(signMessageMock).not.toHaveBeenCalled()
500
+ expect(sendTransactionMock).not.toHaveBeenCalled()
501
+ } finally {
502
+ connectedSession.activeWallet = originalWallet
503
+ }
504
+ })
505
+
506
+ it('a tonConnect session still enters the funnel with no resolved provider', async () => {
507
+ // TON resolves its provider inside the connector, so an undefined provider
508
+ // is the NORMAL case there — the pre-funnel provider check must not break it.
509
+ const originalMode = connectedSession.connectionMode
510
+ const originalWallet = connectedSession.activeWallet
511
+ connectedSession.connectionMode = 'tonConnect'
512
+ connectedSession.activeWallet = {
513
+ id: 'tonkeeper',
514
+ name: 'Tonkeeper'
515
+ } as unknown as WalletMetadata
516
+ const events: UWCTelemetryEvent[] = []
517
+ const uwc = new UniversalWalletConnector({
518
+ networks,
519
+ wallets,
520
+ // Registers the (mocked) TON connector so the real service can route to it.
521
+ tonConnectConfig: { manifestUrl: 'https://x/manifest.json' } as never,
522
+ observer: { onEvent: e => events.push(e) }
523
+ })
524
+
525
+ try {
526
+ await uwc.signMessage('hello')
527
+ const names = events.map(e => e.name)
528
+ expect(names).toContain('awaitingWallet')
529
+ expect(names).toContain('walletSucceeded')
530
+ // The TON connector resolves its own provider — called with the message only.
531
+ expect(tonSignMessageMock).toHaveBeenCalledWith('hello')
532
+ } finally {
533
+ connectedSession.connectionMode = originalMode
534
+ connectedSession.activeWallet = originalWallet
535
+ }
536
+ })
537
+
538
+ it('re-attaches provider lifecycle after a CROSS-namespace switchNetwork', async () => {
539
+ // The live provider (and thus the lifecycle surface) changes when the
540
+ // namespace changes — the connect-time subscription would observe the wrong
541
+ // provider (e.g. EVM listeners on a now-Solana session).
542
+ const originalNetwork = connectedSession.activeNetwork
543
+ switchNetworkMock.mockImplementation(async () => {
544
+ connectedSession.activeNetwork = {
545
+ internalId: 101,
546
+ id: 'solana:mainnet',
547
+ namespace: 'solana',
548
+ name: 'Solana',
549
+ logoUrl: '',
550
+ nativeCurrency: { name: 'SOL', symbol: 'SOL', decimals: 9 },
551
+ networkType: 'solana'
552
+ } as never
553
+ })
554
+ const uwc = new UniversalWalletConnector({ networks, wallets })
555
+
556
+ try {
557
+ await uwc.switchNetwork('solana:mainnet' as never)
558
+ expect(reattachLifecycleMock).toHaveBeenCalledTimes(1)
559
+ } finally {
560
+ connectedSession.activeNetwork = originalNetwork
561
+ }
562
+ })
563
+
564
+ it('does NOT re-attach lifecycle on a same-namespace switchNetwork', async () => {
565
+ const originalNetwork = connectedSession.activeNetwork
566
+ switchNetworkMock.mockImplementation(async () => {
567
+ connectedSession.activeNetwork = {
568
+ ...originalNetwork!,
569
+ internalId: 137,
570
+ id: 'eip155:137',
571
+ name: 'Polygon'
572
+ } as never
573
+ })
574
+ const uwc = new UniversalWalletConnector({ networks, wallets })
575
+
576
+ try {
577
+ await uwc.switchNetwork('eip155:137' as never)
578
+ expect(reattachLifecycleMock).not.toHaveBeenCalled()
579
+ } finally {
580
+ connectedSession.activeNetwork = originalNetwork
581
+ }
582
+ })
583
+
584
+ it('does NOT re-attach lifecycle when the switch fails', async () => {
585
+ switchNetworkMock.mockRejectedValueOnce(new Error('switch failed'))
586
+ const uwc = new UniversalWalletConnector({ networks, wallets })
587
+
588
+ await expect(uwc.switchNetwork('eip155:137' as never)).rejects.toThrow(
589
+ 'switch failed'
590
+ )
591
+ expect(reattachLifecycleMock).not.toHaveBeenCalled()
592
+ })
593
+
594
+ it('dispose() stops further observer delivery AND releases provider-lifecycle listeners', async () => {
595
+ const events: UWCTelemetryEvent[] = []
596
+ const uwc = new UniversalWalletConnector({
597
+ networks,
598
+ wallets,
599
+ observer: { onEvent: e => events.push(e) }
600
+ })
601
+ uwc.dispose()
602
+ // dispose() must tear down the live provider-lifecycle subscription, not just
603
+ // the observer bus — otherwise EIP-1193/adapter `.on` listeners leak when the
604
+ // app disposes a connected session without calling disconnect() first.
605
+ expect(teardownLifecycleMock).toHaveBeenCalledTimes(1)
606
+ events.length = 0
607
+ await uwc.signMessage('hello')
608
+ expect(events).toHaveLength(0)
609
+ })
610
+ })