@meshconnect/uwc-core 1.2.3-snapshot.dcfdbbf → 1.2.3
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/dist/events.d.ts +1 -5
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js.map +1 -1
- package/dist/observability/lifecycle-bridge.d.ts +13 -31
- package/dist/observability/lifecycle-bridge.d.ts.map +1 -1
- package/dist/observability/lifecycle-bridge.js +92 -167
- package/dist/observability/lifecycle-bridge.js.map +1 -1
- package/dist/services/connection-service.d.ts +0 -43
- package/dist/services/connection-service.d.ts.map +1 -1
- package/dist/services/connection-service.js +9 -163
- package/dist/services/connection-service.js.map +1 -1
- package/dist/services/network-switch-service.d.ts.map +1 -1
- package/dist/services/network-switch-service.js +1 -4
- package/dist/services/network-switch-service.js.map +1 -1
- package/package.json +5 -5
- package/src/events.ts +2 -5
- package/src/observability/lifecycle-bridge.test.ts +0 -197
- package/src/observability/lifecycle-bridge.ts +99 -211
- package/src/services/connection-service.test.ts +0 -272
- package/src/services/connection-service.ts +10 -190
- package/src/services/network-switch-service.test.ts +0 -33
- package/src/services/network-switch-service.ts +1 -4
|
@@ -29,23 +29,11 @@ export interface AttachLifecycleOptions {
|
|
|
29
29
|
* The active connector's `onLifecycleEvent` (already bound). `undefined` when the
|
|
30
30
|
* connector can't observe lifecycle at all; the value it returns is `undefined`
|
|
31
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
|
-
* STREAM — `subscribeAccounts` below is attached independently.
|
|
32
|
+
* bridge emits `lifecycleTelemetryUnavailable` and becomes a no-op.
|
|
34
33
|
*/
|
|
35
34
|
subscribe:
|
|
36
35
|
| ((l: (e: WalletLifecycleEvent) => void) => (() => void) | undefined)
|
|
37
36
|
| undefined
|
|
38
|
-
/**
|
|
39
|
-
* The active connector's `onAccountsChanged` (already bound), carrying the
|
|
40
|
-
* REAL account list — a separate concern from `subscribe` above (some
|
|
41
|
-
* connectors, e.g. Tron today, implement one without the other). Absent
|
|
42
|
-
* entirely when the connector doesn't support real account-sync.
|
|
43
|
-
*/
|
|
44
|
-
subscribeAccounts?:
|
|
45
|
-
| ((l: (accounts: string[]) => void) => (() => void) | undefined)
|
|
46
|
-
| undefined
|
|
47
|
-
/** Invoked with the resolved (post-echo-filter) real account list whenever `subscribeAccounts` delivers one. Required whenever `subscribeAccounts` is provided. */
|
|
48
|
-
onAccountsChanged?: (accounts: string[]) => void
|
|
49
37
|
/** Resolved per-event so a mid-session account/chain change is reflected. */
|
|
50
38
|
getRouting: () => LifecycleRouting
|
|
51
39
|
connectionMode: ConnectionMode
|
|
@@ -54,9 +42,8 @@ export interface AttachLifecycleOptions {
|
|
|
54
42
|
/** Direct page vs production iframe — stamped on `lifecycleTelemetryUnavailable`. */
|
|
55
43
|
runtime?: 'direct' | 'iframe'
|
|
56
44
|
/**
|
|
57
|
-
* Window (ms) within which a wallet `chainChanged
|
|
58
|
-
*
|
|
59
|
-
* Default 3000.
|
|
45
|
+
* Window (ms) within which a wallet `chainChanged` is treated as the echo of a
|
|
46
|
+
* UWC-initiated `switchNetwork` and suppressed. Default 3000.
|
|
60
47
|
*/
|
|
61
48
|
echoWindowMs?: number
|
|
62
49
|
/**
|
|
@@ -66,37 +53,29 @@ export interface AttachLifecycleOptions {
|
|
|
66
53
|
* provider's echo would otherwise surface as wallet-initiated. Same one-shot
|
|
67
54
|
* consume semantics as a bus-armed switch.
|
|
68
55
|
*/
|
|
69
|
-
initialSwitch?:
|
|
70
|
-
| { chainId: NetworkId; address?: string; at: number }
|
|
71
|
-
| undefined
|
|
56
|
+
initialSwitch?: { chainId: NetworkId; at: number } | undefined
|
|
72
57
|
now?: () => number
|
|
73
58
|
}
|
|
74
59
|
|
|
75
60
|
/**
|
|
76
61
|
* Bridge a connector's PII-safe `WalletLifecycleEvent` stream onto the event bus
|
|
77
62
|
* as `walletAccountChanged` / `walletChainChanged` / `walletDisconnected` /
|
|
78
|
-
* `walletSessionExpired
|
|
79
|
-
* account list to `onAccountsChanged` for session-sync.
|
|
63
|
+
* `walletSessionExpired`.
|
|
80
64
|
*
|
|
81
|
-
*
|
|
65
|
+
* Two correctness concerns are handled here:
|
|
82
66
|
* - **Echo suppression.** When UWC drives a `switchNetwork`, the wallet emits its
|
|
83
|
-
* own `chainChanged
|
|
84
|
-
*
|
|
85
|
-
* would double-count. We watch the bus for
|
|
86
|
-
*
|
|
87
|
-
* - **Availability is signal
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* `onLifecycleEvent` but implementing `onAccountsChanged` (e.g. Tron today)
|
|
91
|
-
* still gets real account-sync; the two streams are independently attached.
|
|
92
|
-
* - **Shared emission budget.** Both streams count against the same
|
|
93
|
-
* `MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT` ceiling — a storming provider
|
|
94
|
-
* doesn't get double budget just because two streams exist.
|
|
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".
|
|
95
74
|
*
|
|
96
|
-
* @returns a teardown that removes
|
|
75
|
+
* @returns a teardown that removes the connector subscription AND the bus watcher.
|
|
97
76
|
*/
|
|
98
77
|
export function attachLifecycle(opts: AttachLifecycleOptions): () => void {
|
|
99
|
-
const { eventManager, subscribe,
|
|
78
|
+
const { eventManager, subscribe, getRouting } = opts
|
|
100
79
|
const runtime = opts.runtime ?? 'direct'
|
|
101
80
|
const now = opts.now ?? Date.now
|
|
102
81
|
const echoWindowMs = opts.echoWindowMs ?? 3000
|
|
@@ -110,11 +89,11 @@ export function attachLifecycle(opts: AttachLifecycleOptions): () => void {
|
|
|
110
89
|
runtime
|
|
111
90
|
})
|
|
112
91
|
|
|
113
|
-
if (!subscribe
|
|
92
|
+
if (!subscribe) {
|
|
114
93
|
// In the production iframe the injected subscription is deliberately not
|
|
115
94
|
// attempted (the provider is a Comlink proxy) — the BRIDGE, not the connector,
|
|
116
|
-
// is why lifecycle is unobservable. On the direct page, a connector with
|
|
117
|
-
//
|
|
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.
|
|
118
97
|
emitUnavailable(
|
|
119
98
|
runtime === 'iframe'
|
|
120
99
|
? 'bridge_subscription_unavailable'
|
|
@@ -123,206 +102,115 @@ export function attachLifecycle(opts: AttachLifecycleOptions): () => void {
|
|
|
123
102
|
return () => {}
|
|
124
103
|
}
|
|
125
104
|
|
|
126
|
-
// Record UWC-initiated switches so
|
|
127
|
-
// BOTH signals: `awaitingWallet` (operation switchNetwork)
|
|
128
|
-
// handoff START, because some providers emit their chainChanged
|
|
129
|
-
// switch request resolves
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// switch's arm silently erase the FIRST switch's still-unconsumed window,
|
|
137
|
-
// so a delayed wallet echo for A (arriving after B has already completed)
|
|
138
|
-
// would be misclassified as a genuine wallet-initiated change instead of
|
|
139
|
-
// the suppressed echo it actually is.
|
|
140
|
-
type PendingSwitch = {
|
|
141
|
-
chainId?: NetworkId | undefined
|
|
142
|
-
address?: string | undefined
|
|
143
|
-
at: number
|
|
144
|
-
}
|
|
145
|
-
let pendingSwitches: PendingSwitch[] = opts.initialSwitch
|
|
146
|
-
? [opts.initialSwitch]
|
|
147
|
-
: []
|
|
148
|
-
const pruneExpired = () => {
|
|
149
|
-
pendingSwitches = pendingSwitches.filter(p => now() - p.at <= echoWindowMs)
|
|
150
|
-
}
|
|
151
|
-
// `awaitingWallet` then `networkSwitched` are usually the START and
|
|
152
|
-
// COMPLETION of the SAME switch — re-arming updates that existing entry
|
|
153
|
-
// (refreshing `at`, adding the resolved address) rather than pushing a
|
|
154
|
-
// duplicate, which would otherwise leave a phantom entry behind after only
|
|
155
|
-
// one of the two is consumed by a single wallet echo.
|
|
156
|
-
const armOrRearm = (partial: {
|
|
157
|
-
chainId?: NetworkId | undefined
|
|
158
|
-
address?: string | undefined
|
|
159
|
-
}) => {
|
|
160
|
-
pruneExpired()
|
|
161
|
-
if (partial.chainId !== undefined) {
|
|
162
|
-
const idx = pendingSwitches.findIndex(p => p.chainId === partial.chainId)
|
|
163
|
-
if (idx !== -1) {
|
|
164
|
-
pendingSwitches[idx] = {
|
|
165
|
-
...pendingSwitches[idx]!,
|
|
166
|
-
...partial,
|
|
167
|
-
at: now()
|
|
168
|
-
}
|
|
169
|
-
return
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
pendingSwitches.push({ ...partial, at: now() })
|
|
173
|
-
}
|
|
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
|
|
174
115
|
const offAwaiting = eventManager.on('awaitingWallet', data => {
|
|
175
116
|
if (data.operation === 'switchNetwork' && data.chainId !== undefined) {
|
|
176
|
-
|
|
117
|
+
lastSwitch = { chainId: data.chainId, at: now() }
|
|
177
118
|
}
|
|
178
119
|
})
|
|
179
120
|
const offSwitched = eventManager.on('networkSwitched', data => {
|
|
180
|
-
|
|
121
|
+
lastSwitch = { chainId: data.network.id, at: now() }
|
|
181
122
|
})
|
|
182
123
|
const detachBusWatchers = () => {
|
|
183
124
|
offAwaiting()
|
|
184
125
|
offSwitched()
|
|
185
126
|
}
|
|
186
127
|
|
|
187
|
-
// Emission budget for THIS attachment, shared across both streams —
|
|
188
|
-
// suppressed echoes don't spend it.
|
|
189
|
-
let emitted = 0
|
|
190
|
-
let capNotified = false
|
|
191
|
-
const overCap = (): boolean => {
|
|
192
|
-
if (emitted >= MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT) {
|
|
193
|
-
// Mark the trip ONCE — a silent cap would be indistinguishable from a
|
|
194
|
-
// provider that simply went quiet.
|
|
195
|
-
if (!capNotified) {
|
|
196
|
-
capNotified = true
|
|
197
|
-
emitUnavailable('emission_cap')
|
|
198
|
-
}
|
|
199
|
-
return true
|
|
200
|
-
}
|
|
201
|
-
return false
|
|
202
|
-
}
|
|
203
|
-
|
|
204
128
|
// Defense-in-depth: a connector whose subscribe THROWS (e.g. a provider whose
|
|
205
129
|
// `.on` rejects an un-proxied handler across the iframe bridge) must degrade to
|
|
206
130
|
// "unavailable", never break the connect path that called us.
|
|
207
131
|
let unsub: (() => void) | undefined
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const idx =
|
|
226
|
-
e.chainId !== undefined
|
|
227
|
-
? pendingSwitches.findIndex(
|
|
228
|
-
p => p.chainId !== undefined && p.chainId === e.chainId
|
|
229
|
-
)
|
|
230
|
-
: -1
|
|
231
|
-
if (idx !== -1) {
|
|
232
|
-
// Consume the echo: a UWC switch produces exactly one wallet echo, so
|
|
233
|
-
// a LATER chainChanged to the same chain inside the window is a genuine
|
|
234
|
-
// user-initiated switch-back and must not be suppressed. Only the
|
|
235
|
-
// chain half is consumed — a still-pending address echo (if the
|
|
236
|
-
// switch also changed address format) must still be checked; drop the
|
|
237
|
-
// entry entirely once nothing is left pending on it.
|
|
238
|
-
const entry = pendingSwitches[idx]!
|
|
239
|
-
if (entry.address !== undefined) {
|
|
240
|
-
pendingSwitches[idx] = { ...entry, chainId: undefined }
|
|
241
|
-
} else {
|
|
242
|
-
pendingSwitches.splice(idx, 1)
|
|
243
|
-
}
|
|
244
|
-
return
|
|
245
|
-
}
|
|
246
|
-
emitted++
|
|
247
|
-
eventManager.emit('walletChainChanged', {
|
|
248
|
-
...routing,
|
|
249
|
-
...(e.chainId !== undefined ? { chainId: e.chainId } : {})
|
|
250
|
-
})
|
|
251
|
-
return
|
|
252
|
-
}
|
|
253
|
-
case 'disconnect':
|
|
254
|
-
emitted++
|
|
255
|
-
eventManager.emit('walletDisconnected', routing)
|
|
256
|
-
return
|
|
257
|
-
case 'sessionExpired':
|
|
258
|
-
emitted++
|
|
259
|
-
eventManager.emit('walletSessionExpired', routing)
|
|
260
|
-
return
|
|
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')
|
|
261
149
|
}
|
|
262
|
-
|
|
263
|
-
// Swallowed: better to drop one lifecycle record than to break the
|
|
264
|
-
// wallet's event dispatch.
|
|
150
|
+
return
|
|
265
151
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
try {
|
|
285
|
-
unsubAccounts = subscribeAccounts(accounts => {
|
|
286
|
-
try {
|
|
287
|
-
if (overCap()) return
|
|
288
|
-
const primary = accounts[0]
|
|
289
|
-
pruneExpired()
|
|
290
|
-
const idx =
|
|
291
|
-
accounts.length > 0
|
|
292
|
-
? pendingSwitches.findIndex(
|
|
293
|
-
p => p.address !== undefined && p.address === primary
|
|
294
|
-
)
|
|
295
|
-
: -1
|
|
296
|
-
if (idx !== -1) {
|
|
297
|
-
// Same one-shot consume as the chainChanged echo above, mirrored
|
|
298
|
-
// for the address half.
|
|
299
|
-
const entry = pendingSwitches[idx]!
|
|
300
|
-
if (entry.chainId !== undefined) {
|
|
301
|
-
pendingSwitches[idx] = { ...entry, address: undefined }
|
|
302
|
-
} else {
|
|
303
|
-
pendingSwitches.splice(idx, 1)
|
|
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
|
|
304
170
|
}
|
|
171
|
+
emitted++
|
|
172
|
+
eventManager.emit('walletChainChanged', {
|
|
173
|
+
...routing,
|
|
174
|
+
...(e.chainId !== undefined ? { chainId: e.chainId } : {})
|
|
175
|
+
})
|
|
305
176
|
return
|
|
306
177
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
178
|
+
case 'disconnect':
|
|
179
|
+
emitted++
|
|
180
|
+
eventManager.emit('walletDisconnected', routing)
|
|
181
|
+
return
|
|
182
|
+
case 'sessionExpired':
|
|
183
|
+
emitted++
|
|
184
|
+
eventManager.emit('walletSessionExpired', routing)
|
|
185
|
+
return
|
|
311
186
|
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
316
194
|
}
|
|
317
195
|
|
|
318
|
-
|
|
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) {
|
|
319
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
|
+
)
|
|
320
209
|
return () => {}
|
|
321
210
|
}
|
|
322
211
|
|
|
323
212
|
return () => {
|
|
324
213
|
detachBusWatchers()
|
|
325
|
-
unsub
|
|
326
|
-
unsubAccounts?.()
|
|
214
|
+
unsub()
|
|
327
215
|
}
|
|
328
216
|
}
|
|
@@ -863,277 +863,5 @@ describe('ConnectionService', () => {
|
|
|
863
863
|
// No connection → no lifecycle evaluation, so no "unavailable" noise.
|
|
864
864
|
expect(unavail).toHaveLength(0)
|
|
865
865
|
})
|
|
866
|
-
|
|
867
|
-
it('a real accountsChanged from the connector updates activeAddress and emits sessionChanged', async () => {
|
|
868
|
-
let emitAccounts!: (accounts: string[]) => void
|
|
869
|
-
mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
|
|
870
|
-
emitAccounts = l
|
|
871
|
-
return () => {}
|
|
872
|
-
}
|
|
873
|
-
const sessions: Array<{ activeAddress: string | null }> = []
|
|
874
|
-
eventManager.on('sessionChanged', d =>
|
|
875
|
-
sessions.push({ activeAddress: d.session.activeAddress })
|
|
876
|
-
)
|
|
877
|
-
|
|
878
|
-
await connectionService.connect('injected', 'metamask', 'eip155:1')
|
|
879
|
-
emitAccounts(['0xNEW'])
|
|
880
|
-
|
|
881
|
-
expect(sessionManager.getSession().activeAddress).toBe('0xNEW')
|
|
882
|
-
expect(sessions[sessions.length - 1]?.activeAddress).toBe('0xNEW')
|
|
883
|
-
})
|
|
884
|
-
|
|
885
|
-
it('updates the matching availableAddresses entries for the changed namespace', async () => {
|
|
886
|
-
let emitAccounts!: (accounts: string[]) => void
|
|
887
|
-
mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
|
|
888
|
-
emitAccounts = l
|
|
889
|
-
return () => {}
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
await connectionService.connect('injected', 'metamask', 'eip155:1')
|
|
893
|
-
emitAccounts(['0xNEW'])
|
|
894
|
-
|
|
895
|
-
expect(sessionManager.getSession().availableAddresses).toEqual([
|
|
896
|
-
{ address: '0xNEW', networkId: 'eip155:1' }
|
|
897
|
-
])
|
|
898
|
-
})
|
|
899
|
-
|
|
900
|
-
it('an empty accounts list triggers a full disconnect AND calls connector.disconnect for local-state cleanup', async () => {
|
|
901
|
-
// The wallet already ended the session on its own side, but the
|
|
902
|
-
// connector's own LOCAL state (e.g. EthereumWalletService's cached
|
|
903
|
-
// account/uuid) still needs clearing — otherwise a later connect to a
|
|
904
|
-
// DIFFERENT wallet reads the stale cached account via
|
|
905
|
-
// checkExistingConnections() and never rebinds to the new wallet's
|
|
906
|
-
// provider/uuid. disconnect() must still be called here; it's a
|
|
907
|
-
// fire-and-forget, error-swallowed call (see the next test) so a
|
|
908
|
-
// protocol-based connector that rejects on an already-closed session
|
|
909
|
-
// can't break this path.
|
|
910
|
-
let emitAccounts!: (accounts: string[]) => void
|
|
911
|
-
mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
|
|
912
|
-
emitAccounts = l
|
|
913
|
-
return () => {}
|
|
914
|
-
}
|
|
915
|
-
const disconnectSpy = vi.fn().mockResolvedValue(undefined)
|
|
916
|
-
mockConnector.disconnect = disconnectSpy
|
|
917
|
-
|
|
918
|
-
await connectionService.connect('injected', 'metamask', 'eip155:1')
|
|
919
|
-
emitAccounts([])
|
|
920
|
-
|
|
921
|
-
expect(sessionManager.getSession().activeAddress).toBeNull()
|
|
922
|
-
expect(sessionManager.getSession().connectionMode).toBeNull()
|
|
923
|
-
expect(disconnectSpy).toHaveBeenCalledTimes(1)
|
|
924
|
-
})
|
|
925
|
-
|
|
926
|
-
it('does not let a rejecting connector.disconnect() break the wallet-initiated disconnect path', async () => {
|
|
927
|
-
let emitAccounts!: (accounts: string[]) => void
|
|
928
|
-
mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
|
|
929
|
-
emitAccounts = l
|
|
930
|
-
return () => {}
|
|
931
|
-
}
|
|
932
|
-
mockConnector.disconnect = vi
|
|
933
|
-
.fn()
|
|
934
|
-
.mockRejectedValue(new Error('already closed'))
|
|
935
|
-
|
|
936
|
-
await connectionService.connect('injected', 'metamask', 'eip155:1')
|
|
937
|
-
|
|
938
|
-
expect(() => emitAccounts([])).not.toThrow()
|
|
939
|
-
expect(sessionManager.getSession().activeAddress).toBeNull()
|
|
940
|
-
// Let the fire-and-forget rejection settle so it doesn't surface as an
|
|
941
|
-
// unhandled rejection in a later test.
|
|
942
|
-
await Promise.resolve()
|
|
943
|
-
await Promise.resolve()
|
|
944
|
-
})
|
|
945
|
-
|
|
946
|
-
it('a wallet that double-fires an empty accountsChanged does not double-emit disconnected/sessionChanged', async () => {
|
|
947
|
-
// handleAccountsChanged([]) -> finishDisconnect() runs synchronously
|
|
948
|
-
// FROM INSIDE the connector's own accountsChanged dispatch (the
|
|
949
|
-
// teardown it triggers removes that very listener re-entrantly). If a
|
|
950
|
-
// wallet emits the empty-array disconnect signal twice in quick
|
|
951
|
-
// succession — or the second emission slips through before teardown
|
|
952
|
-
// takes effect — finishDisconnect must be a no-op the second time
|
|
953
|
-
// rather than clearing an already-cleared session and re-emitting.
|
|
954
|
-
let emitAccounts!: (accounts: string[]) => void
|
|
955
|
-
mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
|
|
956
|
-
emitAccounts = l
|
|
957
|
-
return () => {}
|
|
958
|
-
}
|
|
959
|
-
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined)
|
|
960
|
-
const disconnectedEvents: unknown[] = []
|
|
961
|
-
const sessionChangedEvents: unknown[] = []
|
|
962
|
-
eventManager.on('disconnected', d => disconnectedEvents.push(d))
|
|
963
|
-
eventManager.on('sessionChanged', d => sessionChangedEvents.push(d))
|
|
964
|
-
|
|
965
|
-
await connectionService.connect('injected', 'metamask', 'eip155:1')
|
|
966
|
-
sessionChangedEvents.length = 0 // drop the connect's own sessionChanged
|
|
967
|
-
|
|
968
|
-
emitAccounts([])
|
|
969
|
-
emitAccounts([]) // the wallet (or a race) delivers the same signal again
|
|
970
|
-
|
|
971
|
-
expect(disconnectedEvents).toHaveLength(1)
|
|
972
|
-
expect(sessionChangedEvents).toHaveLength(1)
|
|
973
|
-
expect(mockConnector.disconnect).toHaveBeenCalledTimes(1)
|
|
974
|
-
})
|
|
975
|
-
|
|
976
|
-
it('reattachLifecycle() seeds address-echo suppression with the just-active address', async () => {
|
|
977
|
-
const accountSubscribers: Array<(accounts: string[]) => void> = []
|
|
978
|
-
mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
|
|
979
|
-
accountSubscribers.push(l)
|
|
980
|
-
return () => {}
|
|
981
|
-
}
|
|
982
|
-
const sessions: unknown[] = []
|
|
983
|
-
eventManager.on('sessionChanged', d => sessions.push(d))
|
|
984
|
-
|
|
985
|
-
await connectionService.connect('injected', 'metamask', 'eip155:1')
|
|
986
|
-
sessions.length = 0
|
|
987
|
-
connectionService.reattachLifecycle()
|
|
988
|
-
|
|
989
|
-
// The reattached provider echoes the session's own current address →
|
|
990
|
-
// suppressed, no redundant sessionChanged.
|
|
991
|
-
accountSubscribers[1]!(['0x1234'])
|
|
992
|
-
expect(sessions).toHaveLength(0)
|
|
993
|
-
|
|
994
|
-
// A later, genuinely different address is a real change.
|
|
995
|
-
accountSubscribers[1]!(['0xDIFFERENT'])
|
|
996
|
-
expect(sessions).toHaveLength(1)
|
|
997
|
-
})
|
|
998
|
-
|
|
999
|
-
it("does not move activeAddress when the changed namespace no longer matches the session's active namespace (stale subscription after a cross-namespace switch)", async () => {
|
|
1000
|
-
let emitAccounts!: (accounts: string[]) => void
|
|
1001
|
-
mockConnector.onAccountsChanged = (l: (accounts: string[]) => void) => {
|
|
1002
|
-
emitAccounts = l
|
|
1003
|
-
return () => {}
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
await connectionService.connect('injected', 'metamask', 'eip155:1')
|
|
1007
|
-
const connectTimeAddress = sessionManager.getSession().activeAddress
|
|
1008
|
-
|
|
1009
|
-
// Simulate the session's active network having already moved to a
|
|
1010
|
-
// different namespace (e.g. mid-cross-namespace-switch) WITHOUT this
|
|
1011
|
-
// stream having been reattached yet — it's still the eip155 one from
|
|
1012
|
-
// connect time.
|
|
1013
|
-
sessionManager.updateSession({
|
|
1014
|
-
activeNetwork: {
|
|
1015
|
-
...sessionManager.getSession().activeNetwork,
|
|
1016
|
-
namespace: 'solana'
|
|
1017
|
-
} as any
|
|
1018
|
-
})
|
|
1019
|
-
|
|
1020
|
-
emitAccounts(['0xNEW'])
|
|
1021
|
-
|
|
1022
|
-
// activeAddress belongs to the now-active (solana) namespace and must
|
|
1023
|
-
// not be clobbered by a stale eip155 subscription's account change.
|
|
1024
|
-
expect(sessionManager.getSession().activeAddress).toBe(connectTimeAddress)
|
|
1025
|
-
// availableAddresses for the eip155 entry IS still updated — the address
|
|
1026
|
-
// change is real, just not the currently-active one.
|
|
1027
|
-
expect(sessionManager.getSession().availableAddresses).toEqual([
|
|
1028
|
-
{ address: '0xNEW', networkId: 'eip155:1' }
|
|
1029
|
-
])
|
|
1030
|
-
})
|
|
1031
|
-
})
|
|
1032
|
-
|
|
1033
|
-
describe('secondary-namespace account sync', () => {
|
|
1034
|
-
// A multi-namespace wallet (e.g. MetaMask with Solana support) connected
|
|
1035
|
-
// on a SOLANA-active session: the cross-namespace gather seeds eip155
|
|
1036
|
-
// addresses into availableAddresses at connect time, but the lifecycle
|
|
1037
|
-
// attach used to observe only the ACTIVE namespace — an eip155 account
|
|
1038
|
-
// switch in the extension was never observed and the session served the
|
|
1039
|
-
// connect-time eip155 addresses forever.
|
|
1040
|
-
let accountSubs: Map<string, (accounts: string[]) => void>
|
|
1041
|
-
let teardownsByNamespace: Map<string, number>
|
|
1042
|
-
|
|
1043
|
-
beforeEach(() => {
|
|
1044
|
-
networks.push({
|
|
1045
|
-
id: 'solana:mainnet',
|
|
1046
|
-
internalId: 101,
|
|
1047
|
-
namespace: 'solana',
|
|
1048
|
-
name: 'Solana',
|
|
1049
|
-
logoUrl: '',
|
|
1050
|
-
nativeCurrency: { name: 'SOL', symbol: 'SOL', decimals: 9 },
|
|
1051
|
-
networkType: 'solana'
|
|
1052
|
-
} as never)
|
|
1053
|
-
wallets[0]!.extensionInjectedProvider = {
|
|
1054
|
-
supportedNetworkIds: ['solana:mainnet', 'eip155:1']
|
|
1055
|
-
} as never
|
|
1056
|
-
mockConnector.connect = vi.fn().mockResolvedValue({
|
|
1057
|
-
networkId: 'solana:mainnet',
|
|
1058
|
-
address: 'SoLAddr111',
|
|
1059
|
-
availableAddresses: [
|
|
1060
|
-
{ address: 'SoLAddr111', networkId: 'solana:mainnet' },
|
|
1061
|
-
{ address: '0x1234', networkId: 'eip155:1' }
|
|
1062
|
-
]
|
|
1063
|
-
})
|
|
1064
|
-
accountSubs = new Map()
|
|
1065
|
-
teardownsByNamespace = new Map()
|
|
1066
|
-
mockConnector.onAccountsChanged = ((
|
|
1067
|
-
listener: (accounts: string[]) => void,
|
|
1068
|
-
context: { namespace: string }
|
|
1069
|
-
) => {
|
|
1070
|
-
accountSubs.set(context.namespace, listener)
|
|
1071
|
-
return () => {
|
|
1072
|
-
teardownsByNamespace.set(
|
|
1073
|
-
context.namespace,
|
|
1074
|
-
(teardownsByNamespace.get(context.namespace) ?? 0) + 1
|
|
1075
|
-
)
|
|
1076
|
-
}
|
|
1077
|
-
}) as never
|
|
1078
|
-
})
|
|
1079
|
-
|
|
1080
|
-
it('attaches an accounts-sync subscription for every namespace present in availableAddresses, not only the active one', async () => {
|
|
1081
|
-
await connectionService.connect('injected', 'metamask', 'solana:mainnet')
|
|
1082
|
-
|
|
1083
|
-
expect([...accountSubs.keys()].sort()).toEqual(['eip155', 'solana'])
|
|
1084
|
-
})
|
|
1085
|
-
|
|
1086
|
-
it("a secondary namespace's accountsChanged updates its availableAddresses entries without touching activeAddress", async () => {
|
|
1087
|
-
await connectionService.connect('injected', 'metamask', 'solana:mainnet')
|
|
1088
|
-
|
|
1089
|
-
accountSubs.get('eip155')!(['0xSWITCHED'])
|
|
1090
|
-
|
|
1091
|
-
const session = sessionManager.getSession()
|
|
1092
|
-
expect(session.activeAddress).toBe('SoLAddr111')
|
|
1093
|
-
expect(session.availableAddresses).toEqual([
|
|
1094
|
-
{ address: 'SoLAddr111', networkId: 'solana:mainnet' },
|
|
1095
|
-
{ address: '0xSWITCHED', networkId: 'eip155:1' }
|
|
1096
|
-
])
|
|
1097
|
-
})
|
|
1098
|
-
|
|
1099
|
-
it('an empty accounts list from a SECONDARY namespace does not disconnect the session', async () => {
|
|
1100
|
-
// Only the ACTIVE namespace's stream may signal the full
|
|
1101
|
-
// wallet-initiated disconnect — a secondary namespace reporting []
|
|
1102
|
-
// means that namespace's own authorization ended, not the session.
|
|
1103
|
-
const disconnectSpy = vi.fn().mockResolvedValue(undefined)
|
|
1104
|
-
mockConnector.disconnect = disconnectSpy
|
|
1105
|
-
|
|
1106
|
-
await connectionService.connect('injected', 'metamask', 'solana:mainnet')
|
|
1107
|
-
accountSubs.get('eip155')!([])
|
|
1108
|
-
|
|
1109
|
-
const session = sessionManager.getSession()
|
|
1110
|
-
expect(session.connectionMode).toBe('injected')
|
|
1111
|
-
expect(session.activeAddress).toBe('SoLAddr111')
|
|
1112
|
-
expect(disconnectSpy).not.toHaveBeenCalled()
|
|
1113
|
-
})
|
|
1114
|
-
|
|
1115
|
-
it('disconnect() tears down the secondary subscriptions too', async () => {
|
|
1116
|
-
await connectionService.connect('injected', 'metamask', 'solana:mainnet')
|
|
1117
|
-
|
|
1118
|
-
await connectionService.disconnect()
|
|
1119
|
-
|
|
1120
|
-
expect(teardownsByNamespace.get('eip155')).toBe(1)
|
|
1121
|
-
expect(teardownsByNamespace.get('solana')).toBe(1)
|
|
1122
|
-
})
|
|
1123
|
-
|
|
1124
|
-
it('reattachLifecycle() re-derives the secondary set for the CURRENT session without stacking duplicates', async () => {
|
|
1125
|
-
await connectionService.connect('injected', 'metamask', 'solana:mainnet')
|
|
1126
|
-
|
|
1127
|
-
connectionService.reattachLifecycle()
|
|
1128
|
-
|
|
1129
|
-
// Old eip155 secondary attachment released, exactly one fresh one live.
|
|
1130
|
-
expect(teardownsByNamespace.get('eip155')).toBe(1)
|
|
1131
|
-
accountSubs.get('eip155')!(['0xAFTER'])
|
|
1132
|
-
expect(
|
|
1133
|
-
sessionManager
|
|
1134
|
-
.getSession()
|
|
1135
|
-
.availableAddresses.find(a => a.networkId === 'eip155:1')?.address
|
|
1136
|
-
).toBe('0xAFTER')
|
|
1137
|
-
})
|
|
1138
866
|
})
|
|
1139
867
|
})
|