@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,398 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { WalletLifecycleEvent, Network } from '@meshconnect/uwc-types'
|
|
3
|
+
import { EventManager } from '../managers/event-manager'
|
|
4
|
+
import {
|
|
5
|
+
attachLifecycle,
|
|
6
|
+
MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT,
|
|
7
|
+
type LifecycleRouting
|
|
8
|
+
} from './lifecycle-bridge'
|
|
9
|
+
|
|
10
|
+
const routing: LifecycleRouting = {
|
|
11
|
+
connectionMode: 'injected',
|
|
12
|
+
walletId: 'metamask',
|
|
13
|
+
namespace: 'eip155'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const switchedTo = (id: string) =>
|
|
17
|
+
({ network: { id } as unknown as Network }) as { network: Network }
|
|
18
|
+
|
|
19
|
+
describe('attachLifecycle', () => {
|
|
20
|
+
it('maps lifecycle events to bus events with routing (no address)', () => {
|
|
21
|
+
const em = new EventManager()
|
|
22
|
+
const got: Record<string, unknown[]> = {}
|
|
23
|
+
for (const n of [
|
|
24
|
+
'walletAccountChanged',
|
|
25
|
+
'walletChainChanged',
|
|
26
|
+
'walletDisconnected',
|
|
27
|
+
'walletSessionExpired'
|
|
28
|
+
] as const) {
|
|
29
|
+
em.on(n, d => (got[n] ??= []).push(d))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let emit!: (e: WalletLifecycleEvent) => void
|
|
33
|
+
attachLifecycle({
|
|
34
|
+
eventManager: em,
|
|
35
|
+
subscribe: l => {
|
|
36
|
+
emit = l
|
|
37
|
+
return () => {}
|
|
38
|
+
},
|
|
39
|
+
getRouting: () => routing,
|
|
40
|
+
connectionMode: 'injected',
|
|
41
|
+
walletId: 'metamask',
|
|
42
|
+
namespace: 'eip155'
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
emit({ type: 'accountChanged' })
|
|
46
|
+
emit({ type: 'chainChanged', chainId: 'eip155:8453' })
|
|
47
|
+
emit({ type: 'disconnect' })
|
|
48
|
+
emit({ type: 'sessionExpired' })
|
|
49
|
+
|
|
50
|
+
expect(got.walletAccountChanged).toEqual([routing])
|
|
51
|
+
expect(got.walletChainChanged).toEqual([
|
|
52
|
+
{ ...routing, chainId: 'eip155:8453' }
|
|
53
|
+
])
|
|
54
|
+
expect(got.walletDisconnected).toEqual([routing])
|
|
55
|
+
expect(got.walletSessionExpired).toEqual([routing])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('a throwing getRouting never escapes into the wallet emitter dispatch', () => {
|
|
59
|
+
// Wallet SDKs dispatch listeners unguarded (e.g. TonConnect's bare
|
|
60
|
+
// forEach) — a throw from our listener would abort delivery to the
|
|
61
|
+
// wallet's own later subscribers.
|
|
62
|
+
const em = new EventManager()
|
|
63
|
+
let emit!: (e: WalletLifecycleEvent) => void
|
|
64
|
+
attachLifecycle({
|
|
65
|
+
eventManager: em,
|
|
66
|
+
subscribe: l => {
|
|
67
|
+
emit = l
|
|
68
|
+
return () => {}
|
|
69
|
+
},
|
|
70
|
+
getRouting: () => {
|
|
71
|
+
throw new Error('poisoned routing')
|
|
72
|
+
},
|
|
73
|
+
connectionMode: 'injected',
|
|
74
|
+
walletId: 'metamask',
|
|
75
|
+
namespace: 'eip155'
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(() => emit({ type: 'accountChanged' })).not.toThrow()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('suppresses a chainChanged that echoes a UWC-initiated switch', () => {
|
|
82
|
+
let t = 1000
|
|
83
|
+
const em = new EventManager()
|
|
84
|
+
const seen: unknown[] = []
|
|
85
|
+
em.on('walletChainChanged', d => seen.push(d))
|
|
86
|
+
|
|
87
|
+
let emit!: (e: WalletLifecycleEvent) => void
|
|
88
|
+
attachLifecycle({
|
|
89
|
+
eventManager: em,
|
|
90
|
+
subscribe: l => {
|
|
91
|
+
emit = l
|
|
92
|
+
return () => {}
|
|
93
|
+
},
|
|
94
|
+
getRouting: () => routing,
|
|
95
|
+
connectionMode: 'injected',
|
|
96
|
+
namespace: 'eip155',
|
|
97
|
+
now: () => t
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// UWC drives the switch → networkSwitched on the bus; the wallet's echo for
|
|
101
|
+
// the same chain inside the window is dropped.
|
|
102
|
+
em.emit('networkSwitched', switchedTo('eip155:8453'))
|
|
103
|
+
emit({ type: 'chainChanged', chainId: 'eip155:8453' })
|
|
104
|
+
expect(seen).toHaveLength(0)
|
|
105
|
+
|
|
106
|
+
// The same chainChanged past the echo window is a genuine wallet-side switch.
|
|
107
|
+
t = 1000 + 5000
|
|
108
|
+
emit({ type: 'chainChanged', chainId: 'eip155:8453' })
|
|
109
|
+
expect(seen).toHaveLength(1)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('suppresses an echo that lands BEFORE networkSwitched (provider emits early)', () => {
|
|
113
|
+
// Some providers emit their own chainChanged BEFORE the switch request
|
|
114
|
+
// resolves — i.e. before UWC emits networkSwitched. The suppression window
|
|
115
|
+
// is therefore ALSO armed from the switch handoff start (awaitingWallet
|
|
116
|
+
// with operation switchNetwork), which always precedes the wallet call.
|
|
117
|
+
let t = 1000
|
|
118
|
+
const em = new EventManager()
|
|
119
|
+
const seen: unknown[] = []
|
|
120
|
+
em.on('walletChainChanged', d => seen.push(d))
|
|
121
|
+
|
|
122
|
+
let emit!: (e: WalletLifecycleEvent) => void
|
|
123
|
+
attachLifecycle({
|
|
124
|
+
eventManager: em,
|
|
125
|
+
subscribe: l => {
|
|
126
|
+
emit = l
|
|
127
|
+
return () => {}
|
|
128
|
+
},
|
|
129
|
+
getRouting: () => routing,
|
|
130
|
+
connectionMode: 'injected',
|
|
131
|
+
namespace: 'eip155',
|
|
132
|
+
now: () => t
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
em.emit('awaitingWallet', {
|
|
136
|
+
operation: 'switchNetwork',
|
|
137
|
+
connectionMode: 'injected',
|
|
138
|
+
walletId: 'metamask',
|
|
139
|
+
namespace: 'eip155',
|
|
140
|
+
chainId: 'eip155:8453',
|
|
141
|
+
handoffId: 'h-switch',
|
|
142
|
+
walletFlowType: 'browser_extension_flow',
|
|
143
|
+
platform: 'desktop'
|
|
144
|
+
})
|
|
145
|
+
// The wallet's early echo — no networkSwitched has fired yet.
|
|
146
|
+
emit({ type: 'chainChanged', chainId: 'eip155:8453' })
|
|
147
|
+
expect(seen).toHaveLength(0)
|
|
148
|
+
|
|
149
|
+
// A LATER user-initiated switch-back to the same chain must still surface.
|
|
150
|
+
t = 1500
|
|
151
|
+
emit({ type: 'chainChanged', chainId: 'eip155:8453' })
|
|
152
|
+
expect(seen).toHaveLength(1)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('caps lifecycle emissions per attachment (a storming provider cannot drive unbounded egress)', () => {
|
|
156
|
+
// Every other telemetry sink added by this layer is bounded (relay / bridge /
|
|
157
|
+
// observer-error all cap at 100); a wallet looping chainChanged must not be
|
|
158
|
+
// the one unbounded path to the observer.
|
|
159
|
+
const em = new EventManager()
|
|
160
|
+
const seen: unknown[] = []
|
|
161
|
+
em.on('walletChainChanged', d => seen.push(d))
|
|
162
|
+
em.on('walletAccountChanged', d => seen.push(d))
|
|
163
|
+
const unavail: Array<{ reason?: string }> = []
|
|
164
|
+
em.on('lifecycleTelemetryUnavailable', d =>
|
|
165
|
+
unavail.push(d as { reason?: string })
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
let emit!: (e: WalletLifecycleEvent) => void
|
|
169
|
+
attachLifecycle({
|
|
170
|
+
eventManager: em,
|
|
171
|
+
subscribe: l => {
|
|
172
|
+
emit = l
|
|
173
|
+
return () => {}
|
|
174
|
+
},
|
|
175
|
+
getRouting: () => routing,
|
|
176
|
+
connectionMode: 'injected',
|
|
177
|
+
namespace: 'eip155'
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
for (let i = 0; i < MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT + 50; i++) {
|
|
181
|
+
emit({ type: 'chainChanged', chainId: `eip155:${i}` })
|
|
182
|
+
}
|
|
183
|
+
expect(seen).toHaveLength(MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT)
|
|
184
|
+
|
|
185
|
+
// Once capped, every lifecycle type stays silenced for this attachment…
|
|
186
|
+
emit({ type: 'accountChanged' })
|
|
187
|
+
expect(seen).toHaveLength(MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT)
|
|
188
|
+
|
|
189
|
+
// …but the trip itself is marked ONCE, so a dashboard can tell "provider
|
|
190
|
+
// went quiet" from "provider stormed and got capped".
|
|
191
|
+
expect(unavail).toHaveLength(1)
|
|
192
|
+
expect(unavail[0]?.reason).toBe('emission_cap')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('seeds echo suppression from initialSwitch (the reattach-after-switch path)', () => {
|
|
196
|
+
// A cross-namespace switchNetwork re-attaches the subscription AFTER the
|
|
197
|
+
// switch completed — the fresh closure never saw that switch's bus events,
|
|
198
|
+
// so without a seed the new provider's echo would surface as wallet-initiated.
|
|
199
|
+
let t = 1000
|
|
200
|
+
const em = new EventManager()
|
|
201
|
+
const seen: unknown[] = []
|
|
202
|
+
em.on('walletChainChanged', d => seen.push(d))
|
|
203
|
+
|
|
204
|
+
let emit!: (e: WalletLifecycleEvent) => void
|
|
205
|
+
attachLifecycle({
|
|
206
|
+
eventManager: em,
|
|
207
|
+
subscribe: l => {
|
|
208
|
+
emit = l
|
|
209
|
+
return () => {}
|
|
210
|
+
},
|
|
211
|
+
getRouting: () => routing,
|
|
212
|
+
connectionMode: 'injected',
|
|
213
|
+
namespace: 'eip155',
|
|
214
|
+
initialSwitch: { chainId: 'eip155:8453', at: 1000 },
|
|
215
|
+
now: () => t
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// The just-switched chain's echo is suppressed…
|
|
219
|
+
emit({ type: 'chainChanged', chainId: 'eip155:8453' })
|
|
220
|
+
expect(seen).toHaveLength(0)
|
|
221
|
+
|
|
222
|
+
// …and CONSUMED: a later same-chain change is genuine and surfaces.
|
|
223
|
+
t = 1500
|
|
224
|
+
emit({ type: 'chainChanged', chainId: 'eip155:8453' })
|
|
225
|
+
expect(seen).toHaveLength(1)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('a connect handoff does not arm echo suppression', () => {
|
|
229
|
+
const em = new EventManager()
|
|
230
|
+
const seen: unknown[] = []
|
|
231
|
+
em.on('walletChainChanged', d => seen.push(d))
|
|
232
|
+
|
|
233
|
+
let emit!: (e: WalletLifecycleEvent) => void
|
|
234
|
+
attachLifecycle({
|
|
235
|
+
eventManager: em,
|
|
236
|
+
subscribe: l => {
|
|
237
|
+
emit = l
|
|
238
|
+
return () => {}
|
|
239
|
+
},
|
|
240
|
+
getRouting: () => routing,
|
|
241
|
+
connectionMode: 'injected',
|
|
242
|
+
namespace: 'eip155'
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
em.emit('awaitingWallet', {
|
|
246
|
+
operation: 'connect',
|
|
247
|
+
connectionMode: 'injected',
|
|
248
|
+
walletId: 'metamask',
|
|
249
|
+
namespace: 'eip155',
|
|
250
|
+
chainId: 'eip155:8453',
|
|
251
|
+
handoffId: 'h-connect',
|
|
252
|
+
walletFlowType: 'browser_extension_flow',
|
|
253
|
+
platform: 'desktop'
|
|
254
|
+
})
|
|
255
|
+
emit({ type: 'chainChanged', chainId: 'eip155:8453' })
|
|
256
|
+
|
|
257
|
+
expect(seen).toHaveLength(1)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('teardown removes the awaitingWallet watcher too (no lingering bus listeners)', () => {
|
|
261
|
+
const em = new EventManager()
|
|
262
|
+
const baseline = em.getListenerCount()
|
|
263
|
+
|
|
264
|
+
const teardown = attachLifecycle({
|
|
265
|
+
eventManager: em,
|
|
266
|
+
subscribe: () => () => {},
|
|
267
|
+
getRouting: () => routing,
|
|
268
|
+
connectionMode: 'injected',
|
|
269
|
+
namespace: 'eip155'
|
|
270
|
+
})
|
|
271
|
+
expect(em.getListenerCount()).toBeGreaterThan(baseline)
|
|
272
|
+
|
|
273
|
+
teardown()
|
|
274
|
+
expect(em.getListenerCount()).toBe(baseline)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('reports unsupported_namespace on the direct page when the connector has no lifecycle support', () => {
|
|
278
|
+
const em = new EventManager()
|
|
279
|
+
const unavail: unknown[] = []
|
|
280
|
+
em.on('lifecycleTelemetryUnavailable', d => unavail.push(d))
|
|
281
|
+
|
|
282
|
+
attachLifecycle({
|
|
283
|
+
eventManager: em,
|
|
284
|
+
subscribe: undefined,
|
|
285
|
+
getRouting: () => routing,
|
|
286
|
+
connectionMode: 'injected',
|
|
287
|
+
namespace: 'eip155',
|
|
288
|
+
walletId: 'metamask'
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
expect(unavail).toEqual([
|
|
292
|
+
{
|
|
293
|
+
reason: 'unsupported_namespace',
|
|
294
|
+
connectionMode: 'injected',
|
|
295
|
+
namespace: 'eip155',
|
|
296
|
+
walletId: 'metamask',
|
|
297
|
+
runtime: 'direct'
|
|
298
|
+
}
|
|
299
|
+
])
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('reports bridge_subscription_unavailable in the iframe (bridge is why lifecycle is silent)', () => {
|
|
303
|
+
const em = new EventManager()
|
|
304
|
+
const unavail: Array<{ reason?: string; runtime?: string }> = []
|
|
305
|
+
em.on('lifecycleTelemetryUnavailable', d =>
|
|
306
|
+
unavail.push(d as { reason?: string; runtime?: string })
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
// No lifecycle surface AND running inside the Link iframe.
|
|
310
|
+
attachLifecycle({
|
|
311
|
+
eventManager: em,
|
|
312
|
+
subscribe: () => undefined,
|
|
313
|
+
getRouting: () => routing,
|
|
314
|
+
connectionMode: 'injected',
|
|
315
|
+
namespace: 'eip155',
|
|
316
|
+
runtime: 'iframe'
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
expect(unavail).toHaveLength(1)
|
|
320
|
+
expect(unavail[0]?.reason).toBe('bridge_subscription_unavailable')
|
|
321
|
+
expect(unavail[0]?.runtime).toBe('iframe')
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('emits unavailable (and does not leak the bus watchers) when subscribe returns undefined', () => {
|
|
325
|
+
const em = new EventManager()
|
|
326
|
+
const unavail: unknown[] = []
|
|
327
|
+
em.on('lifecycleTelemetryUnavailable', d => unavail.push(d))
|
|
328
|
+
const baseline = em.getListenerCount()
|
|
329
|
+
|
|
330
|
+
const teardown = attachLifecycle({
|
|
331
|
+
eventManager: em,
|
|
332
|
+
subscribe: () => undefined, // accepted the call but no observable surface
|
|
333
|
+
getRouting: () => routing,
|
|
334
|
+
connectionMode: 'injected',
|
|
335
|
+
namespace: 'eip155'
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
expect(unavail).toHaveLength(1)
|
|
339
|
+
// Every bus watcher (networkSwitched + awaitingWallet) is removed on this
|
|
340
|
+
// degraded path — nothing lingers for a session that can't be observed.
|
|
341
|
+
expect(em.getListenerCount()).toBe(baseline)
|
|
342
|
+
expect(() => teardown()).not.toThrow()
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('teardown removes the connector sub AND the networkSwitched watcher', () => {
|
|
346
|
+
const em = new EventManager()
|
|
347
|
+
let unsubbed = false
|
|
348
|
+
let emit!: (e: WalletLifecycleEvent) => void
|
|
349
|
+
const teardown = attachLifecycle({
|
|
350
|
+
eventManager: em,
|
|
351
|
+
subscribe: l => {
|
|
352
|
+
emit = l
|
|
353
|
+
return () => {
|
|
354
|
+
unsubbed = true
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
getRouting: () => routing,
|
|
358
|
+
connectionMode: 'injected',
|
|
359
|
+
namespace: 'eip155'
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
teardown()
|
|
363
|
+
expect(unsubbed).toBe(true)
|
|
364
|
+
|
|
365
|
+
// Watcher gone: a post-teardown networkSwitched no longer arms echo-suppression,
|
|
366
|
+
// so a chainChanged for that chain is NOT suppressed.
|
|
367
|
+
const seen: unknown[] = []
|
|
368
|
+
em.on('walletChainChanged', d => seen.push(d))
|
|
369
|
+
em.emit('networkSwitched', switchedTo('eip155:1'))
|
|
370
|
+
emit({ type: 'chainChanged', chainId: 'eip155:1' })
|
|
371
|
+
expect(seen).toHaveLength(1)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('degrades to unavailable (never throws) when subscribe itself throws', () => {
|
|
375
|
+
// A connector whose subscribe throws — e.g. a provider whose `.on` rejects an
|
|
376
|
+
// un-proxied handler across the iframe bridge — must NOT break the connect
|
|
377
|
+
// path; it degrades to lifecycleTelemetryUnavailable.
|
|
378
|
+
const em = new EventManager()
|
|
379
|
+
const unavail: unknown[] = []
|
|
380
|
+
em.on('lifecycleTelemetryUnavailable', d => unavail.push(d))
|
|
381
|
+
|
|
382
|
+
let teardown!: () => void
|
|
383
|
+
expect(() => {
|
|
384
|
+
teardown = attachLifecycle({
|
|
385
|
+
eventManager: em,
|
|
386
|
+
subscribe: () => {
|
|
387
|
+
throw new Error('handler could not be cloned over the bridge')
|
|
388
|
+
},
|
|
389
|
+
getRouting: () => routing,
|
|
390
|
+
connectionMode: 'injected',
|
|
391
|
+
namespace: 'eip155'
|
|
392
|
+
})
|
|
393
|
+
}).not.toThrow()
|
|
394
|
+
|
|
395
|
+
expect(unavail).toHaveLength(1)
|
|
396
|
+
expect(() => teardown()).not.toThrow()
|
|
397
|
+
})
|
|
398
|
+
})
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WalletLifecycleEvent,
|
|
3
|
+
ConnectionMode,
|
|
4
|
+
Namespace,
|
|
5
|
+
NetworkId,
|
|
6
|
+
LifecycleUnavailableReason
|
|
7
|
+
} from '@meshconnect/uwc-types'
|
|
8
|
+
import type { EventManager } from '../managers/event-manager'
|
|
9
|
+
|
|
10
|
+
/** Routing facets stamped on every lifecycle event. No address. */
|
|
11
|
+
export interface LifecycleRouting {
|
|
12
|
+
connectionMode: ConnectionMode
|
|
13
|
+
walletId?: string | undefined
|
|
14
|
+
namespace?: Namespace | undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hard ceiling on lifecycle emissions per attachment. Every other telemetry
|
|
19
|
+
* sink this layer added is bounded (relay / bridge / observer-error dedup sets
|
|
20
|
+
* cap at 100); a provider looping `chainChanged`/`accountsChanged` must not be
|
|
21
|
+
* the one unbounded path to the observer. A legitimate session stays far below
|
|
22
|
+
* this; a storming provider goes silent instead of driving egress cost.
|
|
23
|
+
*/
|
|
24
|
+
export const MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT = 100
|
|
25
|
+
|
|
26
|
+
export interface AttachLifecycleOptions {
|
|
27
|
+
eventManager: EventManager
|
|
28
|
+
/**
|
|
29
|
+
* The active connector's `onLifecycleEvent` (already bound). `undefined` when the
|
|
30
|
+
* connector can't observe lifecycle at all; the value it returns is `undefined`
|
|
31
|
+
* when THIS session can't be observed (e.g. no live `.on`). Either way the
|
|
32
|
+
* bridge emits `lifecycleTelemetryUnavailable` and becomes a no-op.
|
|
33
|
+
*/
|
|
34
|
+
subscribe:
|
|
35
|
+
| ((l: (e: WalletLifecycleEvent) => void) => (() => void) | undefined)
|
|
36
|
+
| undefined
|
|
37
|
+
/** Resolved per-event so a mid-session account/chain change is reflected. */
|
|
38
|
+
getRouting: () => LifecycleRouting
|
|
39
|
+
connectionMode: ConnectionMode
|
|
40
|
+
namespace?: Namespace | undefined
|
|
41
|
+
walletId?: string | undefined
|
|
42
|
+
/** Direct page vs production iframe — stamped on `lifecycleTelemetryUnavailable`. */
|
|
43
|
+
runtime?: 'direct' | 'iframe'
|
|
44
|
+
/**
|
|
45
|
+
* Window (ms) within which a wallet `chainChanged` is treated as the echo of a
|
|
46
|
+
* UWC-initiated `switchNetwork` and suppressed. Default 3000.
|
|
47
|
+
*/
|
|
48
|
+
echoWindowMs?: number
|
|
49
|
+
/**
|
|
50
|
+
* Seed for echo suppression when (re)attaching right AFTER a UWC-initiated
|
|
51
|
+
* switch completed (the cross-namespace reattach path): the fresh closure
|
|
52
|
+
* never saw that switch's `awaitingWallet`/`networkSwitched`, so the new
|
|
53
|
+
* provider's echo would otherwise surface as wallet-initiated. Same one-shot
|
|
54
|
+
* consume semantics as a bus-armed switch.
|
|
55
|
+
*/
|
|
56
|
+
initialSwitch?: { chainId: NetworkId; at: number } | undefined
|
|
57
|
+
now?: () => number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Bridge a connector's PII-safe `WalletLifecycleEvent` stream onto the event bus
|
|
62
|
+
* as `walletAccountChanged` / `walletChainChanged` / `walletDisconnected` /
|
|
63
|
+
* `walletSessionExpired`.
|
|
64
|
+
*
|
|
65
|
+
* Two correctness concerns are handled here:
|
|
66
|
+
* - **Echo suppression.** When UWC drives a `switchNetwork`, the wallet emits its
|
|
67
|
+
* own `chainChanged` for the same chain — that's already captured by UWC's
|
|
68
|
+
* `networkSwitched` / the switch handoff, so re-emitting it as a wallet-
|
|
69
|
+
* initiated `walletChainChanged` would double-count. We watch the bus for
|
|
70
|
+
* `networkSwitched` and drop a matching `chainChanged` inside `echoWindowMs`.
|
|
71
|
+
* - **Availability is signal.** If the connector can't observe lifecycle, we emit
|
|
72
|
+
* `lifecycleTelemetryUnavailable` ONCE so a dashboard can tell "silent" from
|
|
73
|
+
* "unwired".
|
|
74
|
+
*
|
|
75
|
+
* @returns a teardown that removes the connector subscription AND the bus watcher.
|
|
76
|
+
*/
|
|
77
|
+
export function attachLifecycle(opts: AttachLifecycleOptions): () => void {
|
|
78
|
+
const { eventManager, subscribe, getRouting } = opts
|
|
79
|
+
const runtime = opts.runtime ?? 'direct'
|
|
80
|
+
const now = opts.now ?? Date.now
|
|
81
|
+
const echoWindowMs = opts.echoWindowMs ?? 3000
|
|
82
|
+
|
|
83
|
+
const emitUnavailable = (reason: LifecycleUnavailableReason) =>
|
|
84
|
+
eventManager.emit('lifecycleTelemetryUnavailable', {
|
|
85
|
+
reason,
|
|
86
|
+
connectionMode: opts.connectionMode,
|
|
87
|
+
namespace: opts.namespace,
|
|
88
|
+
walletId: opts.walletId,
|
|
89
|
+
runtime
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
if (!subscribe) {
|
|
93
|
+
// In the production iframe the injected subscription is deliberately not
|
|
94
|
+
// attempted (the provider is a Comlink proxy) — the BRIDGE, not the connector,
|
|
95
|
+
// is why lifecycle is unobservable. On the direct page, a connector with no
|
|
96
|
+
// `onLifecycleEvent` simply has no lifecycle support for this namespace yet.
|
|
97
|
+
emitUnavailable(
|
|
98
|
+
runtime === 'iframe'
|
|
99
|
+
? 'bridge_subscription_unavailable'
|
|
100
|
+
: 'unsupported_namespace'
|
|
101
|
+
)
|
|
102
|
+
return () => {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Record UWC-initiated switches so the wallet's chainChanged echo can be
|
|
106
|
+
// dropped. Armed from BOTH signals: `awaitingWallet` (operation switchNetwork)
|
|
107
|
+
// arms at the handoff START, because some providers emit their chainChanged
|
|
108
|
+
// BEFORE the switch request resolves — i.e. before `networkSwitched` exists —
|
|
109
|
+
// and `networkSwitched` re-arms at completion for the late-echo case. The
|
|
110
|
+
// re-arm means one extra suppression window can follow an early echo; the
|
|
111
|
+
// only event it could swallow is a sub-window user re-switch to the chain
|
|
112
|
+
// that just became active — accepted over double-counting every early echo.
|
|
113
|
+
let lastSwitch: { chainId: NetworkId; at: number } | undefined =
|
|
114
|
+
opts.initialSwitch
|
|
115
|
+
const offAwaiting = eventManager.on('awaitingWallet', data => {
|
|
116
|
+
if (data.operation === 'switchNetwork' && data.chainId !== undefined) {
|
|
117
|
+
lastSwitch = { chainId: data.chainId, at: now() }
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
const offSwitched = eventManager.on('networkSwitched', data => {
|
|
121
|
+
lastSwitch = { chainId: data.network.id, at: now() }
|
|
122
|
+
})
|
|
123
|
+
const detachBusWatchers = () => {
|
|
124
|
+
offAwaiting()
|
|
125
|
+
offSwitched()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Defense-in-depth: a connector whose subscribe THROWS (e.g. a provider whose
|
|
129
|
+
// `.on` rejects an un-proxied handler across the iframe bridge) must degrade to
|
|
130
|
+
// "unavailable", never break the connect path that called us.
|
|
131
|
+
let unsub: (() => void) | undefined
|
|
132
|
+
// Emission budget for THIS attachment — suppressed echoes don't spend it.
|
|
133
|
+
let emitted = 0
|
|
134
|
+
let capNotified = false
|
|
135
|
+
try {
|
|
136
|
+
unsub = subscribe(e => {
|
|
137
|
+
// The whole body is guarded: wallet SDKs dispatch their listeners
|
|
138
|
+
// unguarded (e.g. TonConnect's bare `forEach`), so a throw here — from
|
|
139
|
+
// `getRouting()` or any future edit — would abort delivery to the
|
|
140
|
+
// wallet's OWN later subscribers, including one resolving a pending
|
|
141
|
+
// connect. Telemetry must never do that.
|
|
142
|
+
try {
|
|
143
|
+
if (emitted >= MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT) {
|
|
144
|
+
// Mark the trip ONCE — a silent cap would be indistinguishable from a
|
|
145
|
+
// provider that simply went quiet.
|
|
146
|
+
if (!capNotified) {
|
|
147
|
+
capNotified = true
|
|
148
|
+
emitUnavailable('emission_cap')
|
|
149
|
+
}
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
const routing = getRouting()
|
|
153
|
+
switch (e.type) {
|
|
154
|
+
case 'accountChanged':
|
|
155
|
+
emitted++
|
|
156
|
+
eventManager.emit('walletAccountChanged', routing)
|
|
157
|
+
return
|
|
158
|
+
case 'chainChanged': {
|
|
159
|
+
const isEcho =
|
|
160
|
+
!!lastSwitch &&
|
|
161
|
+
e.chainId !== undefined &&
|
|
162
|
+
lastSwitch.chainId === e.chainId &&
|
|
163
|
+
now() - lastSwitch.at <= echoWindowMs
|
|
164
|
+
if (isEcho) {
|
|
165
|
+
// Consume the echo: a UWC switch produces exactly one wallet echo, so
|
|
166
|
+
// a LATER chainChanged to the same chain inside the window is a genuine
|
|
167
|
+
// user-initiated switch-back and must not be suppressed.
|
|
168
|
+
lastSwitch = undefined
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
emitted++
|
|
172
|
+
eventManager.emit('walletChainChanged', {
|
|
173
|
+
...routing,
|
|
174
|
+
...(e.chainId !== undefined ? { chainId: e.chainId } : {})
|
|
175
|
+
})
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
case 'disconnect':
|
|
179
|
+
emitted++
|
|
180
|
+
eventManager.emit('walletDisconnected', routing)
|
|
181
|
+
return
|
|
182
|
+
case 'sessionExpired':
|
|
183
|
+
emitted++
|
|
184
|
+
eventManager.emit('walletSessionExpired', routing)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// Swallowed: better to drop one lifecycle record than to break the
|
|
189
|
+
// wallet's event dispatch.
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
} catch {
|
|
193
|
+
unsub = undefined
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The connector accepted the subscribe call but this session has no observable
|
|
197
|
+
// surface (returned undefined) or threw — treat as "unavailable" and don't
|
|
198
|
+
// leave the bus watchers attached.
|
|
199
|
+
if (!unsub) {
|
|
200
|
+
detachBusWatchers()
|
|
201
|
+
// `onLifecycleEvent` ran but exposed no live subscription surface for this
|
|
202
|
+
// session (returned undefined or threw). Over the bridge, that absence is the
|
|
203
|
+
// bridge's doing, not the provider's.
|
|
204
|
+
emitUnavailable(
|
|
205
|
+
runtime === 'iframe'
|
|
206
|
+
? 'bridge_subscription_unavailable'
|
|
207
|
+
: 'provider_subscription_unavailable'
|
|
208
|
+
)
|
|
209
|
+
return () => {}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return () => {
|
|
213
|
+
detachBusWatchers()
|
|
214
|
+
unsub()
|
|
215
|
+
}
|
|
216
|
+
}
|