@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.
- package/README.md +247 -76
- package/dist/events.d.ts +241 -5
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +77 -1
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/logger/create-logger.d.ts +32 -0
- package/dist/logger/create-logger.d.ts.map +1 -0
- package/dist/logger/create-logger.js +79 -0
- package/dist/logger/create-logger.js.map +1 -0
- package/dist/managers/event-manager.d.ts.map +1 -1
- package/dist/managers/event-manager.js +5 -1
- package/dist/managers/event-manager.js.map +1 -1
- package/dist/observability/connect-observer.d.ts +33 -0
- package/dist/observability/connect-observer.d.ts.map +1 -0
- package/dist/observability/connect-observer.js +115 -0
- package/dist/observability/connect-observer.js.map +1 -0
- package/dist/observability/focus-tracker.d.ts +42 -0
- package/dist/observability/focus-tracker.d.ts.map +1 -0
- package/dist/observability/focus-tracker.js +57 -0
- package/dist/observability/focus-tracker.js.map +1 -0
- package/dist/observability/instrument-handoff.d.ts +54 -0
- package/dist/observability/instrument-handoff.d.ts.map +1 -0
- package/dist/observability/instrument-handoff.js +173 -0
- package/dist/observability/instrument-handoff.js.map +1 -0
- package/dist/observability/lifecycle-bridge.d.ts +69 -0
- package/dist/observability/lifecycle-bridge.d.ts.map +1 -0
- package/dist/observability/lifecycle-bridge.js +155 -0
- package/dist/observability/lifecycle-bridge.js.map +1 -0
- package/dist/observability/scrub.d.ts +31 -0
- package/dist/observability/scrub.d.ts.map +1 -0
- package/dist/observability/scrub.js +167 -0
- package/dist/observability/scrub.js.map +1 -0
- package/dist/observability/telemetry.d.ts +157 -0
- package/dist/observability/telemetry.d.ts.map +1 -0
- package/dist/observability/telemetry.js +373 -0
- package/dist/observability/telemetry.js.map +1 -0
- package/dist/services/connection-service.d.ts +23 -1
- package/dist/services/connection-service.d.ts.map +1 -1
- package/dist/services/connection-service.js +128 -19
- package/dist/services/connection-service.js.map +1 -1
- package/dist/services/network-switch-service.d.ts +2 -1
- package/dist/services/network-switch-service.d.ts.map +1 -1
- package/dist/services/network-switch-service.js +45 -16
- package/dist/services/network-switch-service.js.map +1 -1
- package/dist/services/signature-service.d.ts +14 -7
- package/dist/services/signature-service.d.ts.map +1 -1
- package/dist/services/signature-service.js +56 -58
- package/dist/services/signature-service.js.map +1 -1
- package/dist/services/transaction-service.d.ts +19 -5
- package/dist/services/transaction-service.d.ts.map +1 -1
- package/dist/services/transaction-service.js +55 -38
- package/dist/services/transaction-service.js.map +1 -1
- package/dist/universal-wallet-connector.d.ts +56 -1
- package/dist/universal-wallet-connector.d.ts.map +1 -1
- package/dist/universal-wallet-connector.js +207 -110
- package/dist/universal-wallet-connector.js.map +1 -1
- package/dist/utils/id.d.ts +2 -0
- package/dist/utils/id.d.ts.map +1 -0
- package/dist/utils/id.js +41 -0
- package/dist/utils/id.js.map +1 -0
- package/dist/utils/resolve-session-provider.d.ts +10 -0
- package/dist/utils/resolve-session-provider.d.ts.map +1 -0
- package/dist/utils/resolve-session-provider.js +21 -0
- package/dist/utils/resolve-session-provider.js.map +1 -0
- package/dist/utils/to-wallet-error.d.ts +35 -0
- package/dist/utils/to-wallet-error.d.ts.map +1 -0
- package/dist/utils/to-wallet-error.js +130 -0
- package/dist/utils/to-wallet-error.js.map +1 -0
- package/dist/utils/validate-wallet-session.d.ts +15 -0
- package/dist/utils/validate-wallet-session.d.ts.map +1 -0
- package/dist/utils/validate-wallet-session.js +29 -0
- package/dist/utils/validate-wallet-session.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/package.json +6 -6
- package/src/events.ts +326 -14
- package/src/index.ts +10 -0
- package/src/logger/create-logger.test.ts +126 -0
- package/src/logger/create-logger.ts +112 -0
- package/src/managers/event-manager.test.ts +55 -0
- package/src/managers/event-manager.ts +5 -1
- package/src/observability/connect-observer.test.ts +214 -0
- package/src/observability/connect-observer.ts +128 -0
- package/src/observability/focus-tracker.test.ts +191 -0
- package/src/observability/focus-tracker.ts +96 -0
- package/src/observability/instrument-handoff.test.ts +397 -0
- package/src/observability/instrument-handoff.ts +231 -0
- package/src/observability/lifecycle-bridge.test.ts +398 -0
- package/src/observability/lifecycle-bridge.ts +216 -0
- package/src/observability/scrub.test.ts +183 -0
- package/src/observability/scrub.ts +174 -0
- package/src/observability/telemetry.test.ts +549 -0
- package/src/observability/telemetry.ts +546 -0
- package/src/services/connection-service.test.ts +261 -1
- package/src/services/connection-service.ts +167 -38
- package/src/services/network-switch-service.test.ts +51 -1
- package/src/services/network-switch-service.ts +56 -24
- package/src/services/signature-service.test.ts +181 -76
- package/src/services/signature-service.ts +75 -85
- package/src/services/transaction-service.test.ts +270 -69
- package/src/services/transaction-service.ts +74 -51
- package/src/universal-wallet-connector.instrumentation-guard.test.ts +199 -0
- package/src/universal-wallet-connector.observability.test.ts +610 -0
- package/src/universal-wallet-connector.test.ts +27 -146
- package/src/universal-wallet-connector.ts +279 -142
- package/src/utils/id.test.ts +15 -0
- package/src/utils/id.ts +48 -0
- package/src/utils/resolve-session-provider.test.ts +54 -0
- package/src/utils/resolve-session-provider.ts +25 -0
- package/src/utils/to-wallet-error.test.ts +181 -0
- package/src/utils/to-wallet-error.ts +152 -0
- package/src/utils/validate-wallet-session.test.ts +77 -0
- package/src/utils/validate-wallet-session.ts +40 -0
- package/src/version.test.ts +11 -0
- 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
|
+
}
|