@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,397 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { WalletConnectorError } from '@meshconnect/uwc-types'
3
+ import { EventManager } from '../managers/event-manager'
4
+ import {
5
+ instrumentHandoff,
6
+ hasHandoffTerminal,
7
+ type HandoffContext
8
+ } from './instrument-handoff'
9
+
10
+ const CTX: HandoffContext = {
11
+ operation: 'signMessage',
12
+ connectionMode: 'injected',
13
+ walletId: 'metamask',
14
+ namespace: 'eip155',
15
+ chainId: 'eip155:1'
16
+ }
17
+
18
+ function record(em: EventManager) {
19
+ const seen: Array<{ name: string; data: unknown }> = []
20
+ for (const name of [
21
+ 'awaitingWallet',
22
+ 'walletSucceeded',
23
+ 'walletRejected',
24
+ 'walletFailed',
25
+ 'walletTimedOut'
26
+ ] as const) {
27
+ em.on(name, data => seen.push({ name, data }))
28
+ }
29
+ return seen
30
+ }
31
+
32
+ describe('instrumentHandoff', () => {
33
+ it('emits awaitingWallet then walletSucceeded on success', async () => {
34
+ const em = new EventManager()
35
+ const seen = record(em)
36
+ const result = await instrumentHandoff(
37
+ { eventManager: em, timeoutMs: 0 },
38
+ CTX,
39
+ async () => 'sig'
40
+ )
41
+ expect(result).toBe('sig')
42
+ expect(seen.map(s => s.name)).toEqual(['awaitingWallet', 'walletSucceeded'])
43
+ const start = seen[0].data as { handoffId: string }
44
+ const end = seen[1].data as { handoffId: string; timedOut: boolean }
45
+ expect(end.handoffId).toBe(start.handoffId)
46
+ // A fast op never tripped the marker.
47
+ expect(end.timedOut).toBe(false)
48
+ })
49
+
50
+ it('carries targetAlreadyActive through awaitingWallet and the terminal', async () => {
51
+ const em = new EventManager()
52
+ const seen = record(em)
53
+ await instrumentHandoff(
54
+ { eventManager: em, timeoutMs: 0 },
55
+ { ...CTX, operation: 'switchNetwork', targetAlreadyActive: true },
56
+ async () => 'ok'
57
+ )
58
+ expect(
59
+ (seen[0].data as { targetAlreadyActive?: boolean }).targetAlreadyActive
60
+ ).toBe(true)
61
+ expect(
62
+ (seen[1].data as { targetAlreadyActive?: boolean }).targetAlreadyActive
63
+ ).toBe(true)
64
+ })
65
+
66
+ it('classifies a user rejection (4001) with errorCode/failureKind/source', async () => {
67
+ const em = new EventManager()
68
+ const seen = record(em)
69
+ await expect(
70
+ instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, async () => {
71
+ throw Object.assign(new Error('rejected'), { code: 4001 })
72
+ })
73
+ ).rejects.toThrow()
74
+ const rejected = seen[1].data as {
75
+ errorCode?: string
76
+ failureKind?: string
77
+ source?: string
78
+ }
79
+ expect(rejected.errorCode).toBe('4001')
80
+ expect(rejected.failureKind).toBe('user_rejected')
81
+ expect(rejected.source).toBe('provider')
82
+ })
83
+
84
+ it('classifies an unmapped failure as unknown (no guess)', async () => {
85
+ const em = new EventManager()
86
+ const seen = record(em)
87
+ await expect(
88
+ instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, async () => {
89
+ throw new Error('rpc exploded')
90
+ })
91
+ ).rejects.toThrow()
92
+ const failed = seen[1].data as {
93
+ errorCode?: string
94
+ failureKind?: string
95
+ source?: string
96
+ }
97
+ expect(failed.errorCode).toBeUndefined()
98
+ expect(failed.failureKind).toBe('unknown')
99
+ expect(failed.source).toBe('unknown')
100
+ })
101
+
102
+ it('stamps every terminal with the same routing facets as awaitingWallet', async () => {
103
+ const em = new EventManager()
104
+ const seen = record(em)
105
+ await instrumentHandoff(
106
+ { eventManager: em, timeoutMs: 0 },
107
+ CTX,
108
+ async () => 'sig'
109
+ )
110
+ const routing = {
111
+ connectionMode: CTX.connectionMode,
112
+ walletId: CTX.walletId,
113
+ namespace: CTX.namespace,
114
+ chainId: CTX.chainId
115
+ }
116
+ // awaitingWallet (start) and walletSucceeded (terminal) both self-describe.
117
+ expect(seen[0].data).toMatchObject(routing)
118
+ expect(seen[1].data).toMatchObject(routing)
119
+ })
120
+
121
+ it('emits walletRejected for an explicit user rejection', async () => {
122
+ const em = new EventManager()
123
+ const seen = record(em)
124
+ await expect(
125
+ instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, async () => {
126
+ throw new WalletConnectorError({
127
+ type: 'rejected',
128
+ message: 'user said no'
129
+ })
130
+ })
131
+ ).rejects.toThrow('user said no')
132
+ expect(seen.map(s => s.name)).toEqual(['awaitingWallet', 'walletRejected'])
133
+ })
134
+
135
+ it('emits walletRejected for a raw EIP-1193 4001', async () => {
136
+ const em = new EventManager()
137
+ const seen = record(em)
138
+ await expect(
139
+ instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, async () => {
140
+ throw Object.assign(new Error('rejected'), { code: 4001 })
141
+ })
142
+ ).rejects.toThrow()
143
+ expect(seen.map(s => s.name)).toEqual(['awaitingWallet', 'walletRejected'])
144
+ })
145
+
146
+ it('emits walletFailed (with scrubbed error) for any other error', async () => {
147
+ const em = new EventManager()
148
+ const seen = record(em)
149
+ await expect(
150
+ instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, async () => {
151
+ throw new Error('rpc exploded')
152
+ })
153
+ ).rejects.toThrow('rpc exploded')
154
+ expect(seen.map(s => s.name)).toEqual(['awaitingWallet', 'walletFailed'])
155
+ const failed = seen[1].data as { error: { type: string; message: string } }
156
+ expect(failed.error.type).toBe('unknown')
157
+ expect(failed.error.message).toBe('rpc exploded')
158
+ })
159
+
160
+ it('emits NO terminal for an intentional abort', async () => {
161
+ const em = new EventManager()
162
+ const seen = record(em)
163
+ await expect(
164
+ instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, async () => {
165
+ throw new DOMException('Aborted', 'AbortError')
166
+ })
167
+ ).rejects.toThrow()
168
+ expect(seen.map(s => s.name)).toEqual(['awaitingWallet'])
169
+ })
170
+
171
+ it('emits NO terminal for a custom abort(reason) when the signal is threaded through', async () => {
172
+ // `controller.abort(new Error('nav away'))` rejects with the CUSTOM reason —
173
+ // not an AbortError by name — so the name check alone would mis-file the
174
+ // cancellation as a walletFailed. The signal is the reliable evidence.
175
+ const em = new EventManager()
176
+ const seen = record(em)
177
+ const controller = new AbortController()
178
+ const reason = new Error('nav away')
179
+ await expect(
180
+ instrumentHandoff(
181
+ { eventManager: em, timeoutMs: 0, signal: controller.signal },
182
+ CTX,
183
+ async () => {
184
+ controller.abort(reason)
185
+ throw reason
186
+ }
187
+ )
188
+ ).rejects.toThrow('nav away')
189
+ expect(seen.map(s => s.name)).toEqual(['awaitingWallet'])
190
+ expect(hasHandoffTerminal(reason)).toBe(false)
191
+ })
192
+
193
+ it('still emits walletFailed for a non-abort error while a threaded signal is NOT aborted', async () => {
194
+ const em = new EventManager()
195
+ const seen = record(em)
196
+ const controller = new AbortController()
197
+ await expect(
198
+ instrumentHandoff(
199
+ { eventManager: em, timeoutMs: 0, signal: controller.signal },
200
+ CTX,
201
+ async () => {
202
+ throw new Error('rpc exploded')
203
+ }
204
+ )
205
+ ).rejects.toThrow('rpc exploded')
206
+ expect(seen.map(s => s.name)).toEqual(['awaitingWallet', 'walletFailed'])
207
+ })
208
+
209
+ it('a custom abort(reason) WITHOUT a threaded signal still lands as walletFailed (documented residual)', async () => {
210
+ // With no signal to consult, a custom reason is indistinguishable from a
211
+ // real wallet error — the caller opts into precise cancellation telemetry
212
+ // by passing the signal through the op's options.
213
+ const em = new EventManager()
214
+ const seen = record(em)
215
+ await expect(
216
+ instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, async () => {
217
+ throw new Error('nav away')
218
+ })
219
+ ).rejects.toThrow('nav away')
220
+ expect(seen.map(s => s.name)).toEqual(['awaitingWallet', 'walletFailed'])
221
+ })
222
+
223
+ describe('timeout marker', () => {
224
+ beforeEach(() => vi.useFakeTimers())
225
+ afterEach(() => vi.useRealTimers())
226
+
227
+ it('fires walletTimedOut while pending, then still resolves with walletSucceeded', async () => {
228
+ const em = new EventManager()
229
+ const seen = record(em)
230
+ let resolveFn: (v: string) => void = () => {}
231
+ const promise = instrumentHandoff(
232
+ { eventManager: em, timeoutMs: 1000 },
233
+ CTX,
234
+ () => new Promise<string>(r => (resolveFn = r))
235
+ )
236
+ // Deadline passes with the op still pending → side marker fires.
237
+ await vi.advanceTimersByTimeAsync(1000)
238
+ expect(seen.map(s => s.name)).toContain('walletTimedOut')
239
+ const marker = seen.find(s => s.name === 'walletTimedOut')!.data as {
240
+ timeoutMs: number
241
+ }
242
+ expect(marker.timeoutMs).toBe(1000)
243
+ // The op then completes → real terminal still fires.
244
+ resolveFn('late-sig')
245
+ await expect(promise).resolves.toBe('late-sig')
246
+ expect(seen.map(s => s.name)).toEqual([
247
+ 'awaitingWallet',
248
+ 'walletTimedOut',
249
+ 'walletSucceeded'
250
+ ])
251
+ // The late terminal records that it overran the deadline.
252
+ const late = seen.find(s => s.name === 'walletSucceeded')!.data as {
253
+ timedOut: boolean
254
+ timeoutMs?: number
255
+ }
256
+ expect(late.timedOut).toBe(true)
257
+ expect(late.timeoutMs).toBe(1000)
258
+ })
259
+
260
+ it('does NOT fire walletTimedOut for an op that settles before the deadline', async () => {
261
+ const em = new EventManager()
262
+ const seen = record(em)
263
+ await instrumentHandoff(
264
+ { eventManager: em, timeoutMs: 1000 },
265
+ CTX,
266
+ async () => 'fast'
267
+ )
268
+ await vi.advanceTimersByTimeAsync(5000)
269
+ expect(seen.map(s => s.name)).toEqual([
270
+ 'awaitingWallet',
271
+ 'walletSucceeded'
272
+ ])
273
+ })
274
+
275
+ it('never arms the timer for tonConnect (already bounded by TON)', async () => {
276
+ const em = new EventManager()
277
+ const seen = record(em)
278
+ let resolveFn: (v: string) => void = () => {}
279
+ const promise = instrumentHandoff(
280
+ { eventManager: em, timeoutMs: 1000 },
281
+ { ...CTX, connectionMode: 'tonConnect' },
282
+ () => new Promise<string>(r => (resolveFn = r))
283
+ )
284
+ await vi.advanceTimersByTimeAsync(5000)
285
+ expect(seen.map(s => s.name)).not.toContain('walletTimedOut')
286
+ resolveFn('ok')
287
+ await promise
288
+ })
289
+ })
290
+
291
+ describe('focus facts during a pending handoff (P4)', () => {
292
+ const setVisibility = (state: string) =>
293
+ Object.defineProperty(document, 'visibilityState', {
294
+ value: state,
295
+ configurable: true
296
+ })
297
+
298
+ afterEach(() => setVisibility('visible'))
299
+
300
+ it('records a tab-hide while pending and stops once the op settles', async () => {
301
+ const em = new EventManager()
302
+ const focus: string[] = []
303
+ em.on('pageHiddenDuringHandoff', () => focus.push('hidden'))
304
+ em.on('pageVisibleDuringHandoff', () => focus.push('visible'))
305
+
306
+ let resolveFn: (v: string) => void = () => {}
307
+ const handoff = instrumentHandoff(
308
+ { eventManager: em, timeoutMs: 0 },
309
+ CTX,
310
+ () => new Promise<string>(r => (resolveFn = r))
311
+ )
312
+
313
+ // While pending, a tab-hide records the app-switch fact.
314
+ setVisibility('hidden')
315
+ document.dispatchEvent(new Event('visibilitychange'))
316
+ expect(focus).toEqual(['hidden'])
317
+
318
+ // After the op settles the listeners are torn down — later transitions are
319
+ // NOT recorded (focus is observed only during the blind window).
320
+ resolveFn('ok')
321
+ await handoff
322
+ setVisibility('visible')
323
+ document.dispatchEvent(new Event('visibilitychange'))
324
+ expect(focus).toEqual(['hidden'])
325
+ })
326
+ })
327
+
328
+ describe('hasHandoffTerminal', () => {
329
+ // The marker lets `emitError` tag its back-compat `error` record as
330
+ // `handoffCovered` when a funnel terminal already reported the same failure
331
+ // — the structural fix for dashboard double-counting.
332
+ it('marks an error that produced a walletFailed terminal', async () => {
333
+ const em = new EventManager()
334
+ const boom = new Error('wallet exploded')
335
+
336
+ await expect(
337
+ instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, () =>
338
+ Promise.reject(boom)
339
+ )
340
+ ).rejects.toThrow('wallet exploded')
341
+
342
+ expect(hasHandoffTerminal(boom)).toBe(true)
343
+ })
344
+
345
+ it('marks an error that produced a walletRejected terminal', async () => {
346
+ const em = new EventManager()
347
+ const rejection = new WalletConnectorError({
348
+ type: 'rejected',
349
+ message: 'User rejected'
350
+ })
351
+
352
+ await expect(
353
+ instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, () =>
354
+ Promise.reject(rejection)
355
+ )
356
+ ).rejects.toThrow('User rejected')
357
+
358
+ expect(hasHandoffTerminal(rejection)).toBe(true)
359
+ })
360
+
361
+ it('does NOT mark an abort (no terminal was emitted)', async () => {
362
+ const em = new EventManager()
363
+ const abort = new DOMException('Aborted', 'AbortError')
364
+
365
+ await expect(
366
+ instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, () =>
367
+ Promise.reject(abort)
368
+ )
369
+ ).rejects.toThrow()
370
+
371
+ expect(hasHandoffTerminal(abort)).toBe(false)
372
+ })
373
+
374
+ it('does NOT mark an error that never went through a handoff', () => {
375
+ expect(hasHandoffTerminal(new Error('validation failed'))).toBe(false)
376
+ expect(hasHandoffTerminal('a string throw')).toBe(false)
377
+ expect(hasHandoffTerminal(null)).toBe(false)
378
+ })
379
+
380
+ it('pins the primitive-throw gap: the terminal fires but the value cannot be marked', async () => {
381
+ // A WeakSet cannot hold primitives, so a wallet that rejects with a bare
382
+ // string produces a walletFailed terminal AND an uncovered back-compat
383
+ // `error` record — a documented double-count under the "terminals +
384
+ // uncovered error" metric. Wrapping the thrown value to make it markable
385
+ // would mutate what the caller receives, so the gap is accepted and pinned.
386
+ const em = new EventManager()
387
+ const seen = record(em)
388
+ await expect(
389
+ instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, () =>
390
+ Promise.reject('wallet said no')
391
+ )
392
+ ).rejects.toBe('wallet said no')
393
+ expect(seen.map(s => s.name)).toEqual(['awaitingWallet', 'walletFailed'])
394
+ expect(hasHandoffTerminal('wallet said no')).toBe(false)
395
+ })
396
+ })
397
+ })
@@ -0,0 +1,231 @@
1
+ import type {
2
+ ConnectionMode,
3
+ Namespace,
4
+ NetworkId,
5
+ Session
6
+ } from '@meshconnect/uwc-types'
7
+ import type { EventManager } from '../managers/event-manager'
8
+ import type { UWCOperation } from '../events'
9
+ import { generateId } from '../utils/id'
10
+ import {
11
+ classifyError,
12
+ isAbortError,
13
+ isUserRejection,
14
+ toWalletError
15
+ } from '../utils/to-wallet-error'
16
+ import { derivePlatform, deriveWalletFlowType } from './telemetry'
17
+ import { trackFocusDuringHandoff } from './focus-tracker'
18
+
19
+ /** Routing facets known at the moment of a wallet handoff. No address, no payload. */
20
+ export interface HandoffContext {
21
+ operation: UWCOperation
22
+ connectionMode: ConnectionMode
23
+ walletId?: string | undefined
24
+ namespace?: Namespace | undefined
25
+ chainId?: NetworkId | undefined
26
+ /** switchNetwork only: target chain already the active one (no-op switch). */
27
+ targetAlreadyActive?: boolean | undefined
28
+ }
29
+
30
+ /** Build handoff routing facets from the active session (no address). */
31
+ export function sessionHandoffContext(
32
+ operation: UWCOperation,
33
+ session: Session
34
+ ): HandoffContext {
35
+ return {
36
+ operation,
37
+ connectionMode: session.connectionMode ?? 'injected',
38
+ walletId: session.activeWallet?.id,
39
+ namespace: session.activeNetwork?.namespace,
40
+ chainId: session.activeNetwork?.id ?? undefined
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Errors that already produced a funnel terminal (`walletRejected` /
46
+ * `walletFailed`). `emitError` reads this to tag its back-compat `error` record
47
+ * as `handoffCovered`, so a dashboard can count wallet failures from terminals
48
+ * and pre-wallet failures from `error` without double-counting. A WeakSet keyed
49
+ * on the error object — no mutation of the thrown value, and entries die with
50
+ * the error.
51
+ *
52
+ * DOCUMENTED GAP: a WeakSet cannot hold primitives. A wallet that rejects with
53
+ * a bare string/number still produces its terminal, but the façade's `error`
54
+ * record can't be marked — that failure double-counts under the "terminals +
55
+ * uncovered `error`" metric. Accepted: EIP-1193 requires object errors, and
56
+ * wrapping the thrown value to make it markable would mutate what the caller
57
+ * receives.
58
+ */
59
+ const handoffTerminalErrors = new WeakSet<object>()
60
+
61
+ /** Whether this thrown value already produced a handoff terminal. */
62
+ export function hasHandoffTerminal(error: unknown): boolean {
63
+ return (
64
+ typeof error === 'object' &&
65
+ error !== null &&
66
+ handoffTerminalErrors.has(error)
67
+ )
68
+ }
69
+
70
+ function markHandoffTerminal(error: unknown): void {
71
+ if (typeof error === 'object' && error !== null) {
72
+ handoffTerminalErrors.add(error)
73
+ }
74
+ }
75
+
76
+ export interface InstrumentHandoffOptions {
77
+ eventManager: EventManager
78
+ /**
79
+ * Deadline after which `walletTimedOut` fires as a SIDE marker (never rejects).
80
+ * `0` disables the marker. `tonConnect` is always skipped — TON bounds its own
81
+ * wallet response internally, so a second observational timer would be noise.
82
+ */
83
+ timeoutMs: number
84
+ /**
85
+ * The caller's cancellation signal, when the op accepts one. Consulted in the
86
+ * catch path: a rejection observed while the signal is aborted is treated as
87
+ * the cancellation itself (no terminal) even when the rejection value is a
88
+ * custom `abort(reason)` — which is NOT an AbortError by name.
89
+ */
90
+ signal?: AbortSignal | undefined
91
+ /** Clock injectable for deterministic tests. */
92
+ now?: () => number
93
+ }
94
+
95
+ /**
96
+ * Instrument one wallet-boundary op so the blind window is measured.
97
+ *
98
+ * Emits `awaitingWallet` immediately (before the await that hands control to the
99
+ * wallet), then exactly one terminal — `walletSucceeded` (success),
100
+ * `walletRejected` (explicit user no), or `walletFailed` (any other error) — all
101
+ * carrying the same `handoffId` and a `durationMs`. An intentional abort emits no
102
+ * terminal (the caller cancelled; it isn't a wallet outcome) — services check
103
+ * their signal AFTER this wrapper returns, so a wallet op that settled before
104
+ * the abort still records its truthful terminal first.
105
+ *
106
+ * A non-cancelling `walletTimedOut` marker fires if the deadline passes while the
107
+ * op is still pending; it is cleared the instant the op settles, so a fast op
108
+ * never false-fires it, and it NEVER rejects/aborts/mutates state — the wrapped
109
+ * promise stays the single terminal source of truth. This is a deliberate
110
+ * deviation from TON's cancelling `withWalletResponseTimeout`.
111
+ */
112
+ export async function instrumentHandoff<T>(
113
+ options: InstrumentHandoffOptions,
114
+ context: HandoffContext,
115
+ fn: () => Promise<T>
116
+ ): Promise<T> {
117
+ const { eventManager, timeoutMs } = options
118
+ const now = options.now ?? Date.now
119
+
120
+ const handoffId = generateId()
121
+ const platform = derivePlatform()
122
+ const start = now()
123
+ // Flipped by the observational timeout timer; rides every terminal so a late
124
+ // completion is distinguishable from a fast one without a handoffId self-join.
125
+ let timedOut = false
126
+
127
+ eventManager.emit('awaitingWallet', {
128
+ operation: context.operation,
129
+ connectionMode: context.connectionMode,
130
+ walletId: context.walletId,
131
+ namespace: context.namespace,
132
+ chainId: context.chainId,
133
+ handoffId,
134
+ walletFlowType: deriveWalletFlowType(context.connectionMode, platform),
135
+ platform,
136
+ ...(context.targetAlreadyActive !== undefined && {
137
+ targetAlreadyActive: context.targetAlreadyActive
138
+ })
139
+ })
140
+
141
+ // Observe page-visibility transitions ONLY while this handoff is pending — the
142
+ // app-switch signal for deep-link flows (left for the wallet / came back). The
143
+ // teardown in `finally` removes the listeners the instant the op settles.
144
+ const stopFocus = trackFocusDuringHandoff(eventManager, handoffId, platform)
145
+
146
+ // Same routing facets as `awaitingWallet` so every terminal is self-describing
147
+ // (no handoffId self-join needed to know which wallet / chain / mode it closed).
148
+ const routing = {
149
+ connectionMode: context.connectionMode,
150
+ walletId: context.walletId,
151
+ namespace: context.namespace,
152
+ chainId: context.chainId,
153
+ ...(context.targetAlreadyActive !== undefined && {
154
+ targetAlreadyActive: context.targetAlreadyActive
155
+ })
156
+ }
157
+
158
+ const shouldTime = timeoutMs > 0 && context.connectionMode !== 'tonConnect'
159
+ const timer: ReturnType<typeof setTimeout> | undefined = shouldTime
160
+ ? setTimeout(() => {
161
+ timedOut = true
162
+ eventManager.emit('walletTimedOut', {
163
+ handoffId,
164
+ operation: context.operation,
165
+ durationMs: now() - start,
166
+ timeoutMs,
167
+ ...routing
168
+ })
169
+ }, timeoutMs)
170
+ : undefined
171
+
172
+ try {
173
+ const result = await fn()
174
+ eventManager.emit('walletSucceeded', {
175
+ handoffId,
176
+ operation: context.operation,
177
+ durationMs: now() - start,
178
+ timedOut,
179
+ timeoutMs: timedOut ? timeoutMs : undefined,
180
+ ...routing
181
+ })
182
+ return result
183
+ } catch (error) {
184
+ // Caller-driven cancellation isn't a wallet outcome — leave it unterminated,
185
+ // mirroring the façade's `emitError` AbortError skip. Checked BEFORE rejection
186
+ // is safe: real user rejections surface as WalletConnectorError{rejected} or
187
+ // EIP-1193 4001 (see isUserRejection), never as an AbortError — within UWC's
188
+ // dependency surface AbortError is produced only by the caller's AbortSignal.
189
+ // The signal covers `abort(customReason)`, whose rejection value carries the
190
+ // caller's own name; a real wallet failure that lands while the caller has
191
+ // already aborted is intentionally folded into the cancellation — the caller
192
+ // walked away, so the outcome no longer describes a user-visible interaction.
193
+ if (isAbortError(error) || options.signal?.aborted) throw error
194
+
195
+ const durationMs = now() - start
196
+ const { errorCode, failureKind, source } = classifyError(error)
197
+ // Either branch below emits a terminal for this error — record that so the
198
+ // façade's back-compat `error` event can mark itself as a duplicate.
199
+ markHandoffTerminal(error)
200
+ if (isUserRejection(error)) {
201
+ eventManager.emit('walletRejected', {
202
+ handoffId,
203
+ operation: context.operation,
204
+ durationMs,
205
+ timedOut,
206
+ timeoutMs: timedOut ? timeoutMs : undefined,
207
+ errorCode,
208
+ failureKind,
209
+ source,
210
+ ...routing
211
+ })
212
+ } else {
213
+ eventManager.emit('walletFailed', {
214
+ handoffId,
215
+ operation: context.operation,
216
+ durationMs,
217
+ error: toWalletError(error),
218
+ timedOut,
219
+ timeoutMs: timedOut ? timeoutMs : undefined,
220
+ errorCode,
221
+ failureKind,
222
+ source,
223
+ ...routing
224
+ })
225
+ }
226
+ throw error
227
+ } finally {
228
+ if (timer) clearTimeout(timer)
229
+ stopFocus()
230
+ }
231
+ }