@meshconnect/uwc-core 1.10.4 → 2.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/dist/events.d.ts +46 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +2 -0
- package/dist/events.js.map +1 -1
- package/dist/observability/instrument-handoff.d.ts +8 -1
- package/dist/observability/instrument-handoff.d.ts.map +1 -1
- package/dist/observability/instrument-handoff.js +9 -2
- package/dist/observability/instrument-handoff.js.map +1 -1
- package/dist/observability/lifecycle-bridge.d.ts +31 -13
- package/dist/observability/lifecycle-bridge.d.ts.map +1 -1
- package/dist/observability/lifecycle-bridge.js +194 -92
- package/dist/observability/lifecycle-bridge.js.map +1 -1
- package/dist/observability/telemetry.d.ts +15 -1
- package/dist/observability/telemetry.d.ts.map +1 -1
- package/dist/observability/telemetry.js +15 -0
- package/dist/observability/telemetry.js.map +1 -1
- package/dist/services/connection-service.d.ts +109 -0
- package/dist/services/connection-service.d.ts.map +1 -1
- package/dist/services/connection-service.js +407 -22
- 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 +4 -1
- package/dist/services/network-switch-service.js.map +1 -1
- package/dist/services/signature-service.d.ts +4 -3
- package/dist/services/signature-service.d.ts.map +1 -1
- package/dist/services/signature-service.js +2 -1
- package/dist/services/signature-service.js.map +1 -1
- package/dist/universal-wallet-connector.d.ts +15 -3
- package/dist/universal-wallet-connector.d.ts.map +1 -1
- package/dist/universal-wallet-connector.js +31 -2
- package/dist/universal-wallet-connector.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +5 -5
- package/src/events.ts +51 -3
- package/src/observability/instrument-handoff.ts +8 -2
- package/src/observability/lifecycle-bridge.test.ts +316 -0
- package/src/observability/lifecycle-bridge.ts +240 -99
- package/src/observability/telemetry.ts +32 -1
- package/src/services/connection-service.reentrancy.test.ts +87 -0
- package/src/services/connection-service.test.ts +798 -1
- package/src/services/connection-service.ts +492 -39
- package/src/services/network-switch-service.test.ts +33 -0
- package/src/services/network-switch-service.ts +4 -1
- package/src/services/signature-service.test.ts +9 -2
- package/src/services/signature-service.ts +4 -3
- package/src/universal-wallet-connector.observability.test.ts +126 -3
- package/src/universal-wallet-connector.ts +43 -4
- package/src/version.ts +1 -1
|
@@ -22,10 +22,23 @@ export interface ServiceOptions {
|
|
|
22
22
|
signal?: AbortSignal
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** Sibling-namespace live-account re-read poll: absorbs the lag while a wallet
|
|
26
|
+
* re-derives a sibling namespace's address (e.g. MetaMask's Solana Snap) after
|
|
27
|
+
* an account switch on another namespace. Bounded so a genuinely-unchanged
|
|
28
|
+
* sibling gives up quickly. */
|
|
29
|
+
export const SIBLING_REFRESH_MAX_ATTEMPTS = 5
|
|
30
|
+
export const SIBLING_REFRESH_DELAY_MS = 200
|
|
31
|
+
|
|
25
32
|
export class ConnectionService {
|
|
26
33
|
private activeConnector: Connector | null = null
|
|
27
34
|
/** Tears down the post-connect lifecycle subscription; set while connected. */
|
|
28
35
|
private lifecycleTeardown: (() => void) | undefined
|
|
36
|
+
/**
|
|
37
|
+
* Teardowns for the accounts-sync attachments of the session's SECONDARY
|
|
38
|
+
* namespaces (see `attachSecondaryAccountSync`); set while connected,
|
|
39
|
+
* cleared together with `lifecycleTeardown`.
|
|
40
|
+
*/
|
|
41
|
+
private secondaryAccountSyncTeardowns: Array<() => void> = []
|
|
29
42
|
/**
|
|
30
43
|
* Abort handle for the CURRENT in-flight connect attempt. A re-entrant
|
|
31
44
|
* connect() aborts this before starting, so a superseded attempt's URI poll
|
|
@@ -34,6 +47,26 @@ export class ConnectionService {
|
|
|
34
47
|
* each reading the shared activeConnector, and flood telemetry).
|
|
35
48
|
*/
|
|
36
49
|
private inFlightAbort: AbortController | null = null
|
|
50
|
+
/**
|
|
51
|
+
* Monotonic token for the in-flight cross-namespace sibling refresh (see
|
|
52
|
+
* `refreshSiblingNamespaces`). A newer account switch, a teardown, or a
|
|
53
|
+
* disconnect bumps it so a still-polling older refresh aborts before it
|
|
54
|
+
* writes a now-stale sibling address into the session.
|
|
55
|
+
*/
|
|
56
|
+
private siblingRefreshGeneration = 0
|
|
57
|
+
/**
|
|
58
|
+
* Routing for the connect currently inside `instrumentHandoff`, read by the
|
|
59
|
+
* connector's step sink so each `walletStep` joins its own attempt's terminal
|
|
60
|
+
* (ONC-183). Carries `walletId` too: during a connect the session still holds
|
|
61
|
+
* the PREVIOUS wallet (it is only updated once the handoff resolves), so
|
|
62
|
+
* reading the wallet off the session would label every step with the wrong
|
|
63
|
+
* one. A single slot is sufficient because connects are serialized by the
|
|
64
|
+
* supersession above — a re-entrant connect aborts the previous attempt
|
|
65
|
+
* before starting — and it is cleared the moment the handoff settles, so a
|
|
66
|
+
* step arriving outside one is reported unattributed rather than being folded
|
|
67
|
+
* into a finished attempt.
|
|
68
|
+
*/
|
|
69
|
+
private currentHandoff: { handoffId: string; walletId: string } | undefined
|
|
37
70
|
|
|
38
71
|
constructor(
|
|
39
72
|
private networks: Network[],
|
|
@@ -114,37 +147,28 @@ export class ConnectionService {
|
|
|
114
147
|
namespace: network.namespace,
|
|
115
148
|
chainId: network.id
|
|
116
149
|
},
|
|
117
|
-
async
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
provider as
|
|
127
|
-
| ExtensionInjectedProvider
|
|
128
|
-
| IntegratedBrowserInjectedProvider
|
|
129
|
-
),
|
|
130
|
-
signal
|
|
131
|
-
)
|
|
132
|
-
} else if (
|
|
133
|
-
connectionMode === 'tonConnect' ||
|
|
134
|
-
connectionMode === 'walletConnect'
|
|
135
|
-
) {
|
|
136
|
-
return await raceAbort(
|
|
137
|
-
this.connectWithURIPoll(
|
|
138
|
-
connector,
|
|
139
|
-
network,
|
|
140
|
-
provider,
|
|
141
|
-
connectionMode,
|
|
142
|
-
signal
|
|
143
|
-
),
|
|
150
|
+
async handoffId => {
|
|
151
|
+
// Publish this attempt's routing for the connector's step sink.
|
|
152
|
+
this.currentHandoff = { handoffId, walletId }
|
|
153
|
+
try {
|
|
154
|
+
return await this.runConnect(
|
|
155
|
+
connectionMode,
|
|
156
|
+
connector,
|
|
157
|
+
network,
|
|
158
|
+
provider,
|
|
144
159
|
signal
|
|
145
160
|
)
|
|
146
|
-
}
|
|
147
|
-
|
|
161
|
+
} finally {
|
|
162
|
+
// Retract ONLY if we are still the published attempt. An
|
|
163
|
+
// unconditional clear was wrong: a superseded attempt A rejects via
|
|
164
|
+
// its abort listener, and that rejection's continuation runs in a
|
|
165
|
+
// microtask — i.e. AFTER the re-entrant attempt B has already
|
|
166
|
+
// synchronously published its own handoff. A's `finally` then wiped
|
|
167
|
+
// B's live value, leaving a still-pending B whose steps reported no
|
|
168
|
+
// handoffId at all (ONC-183 review, reproduced).
|
|
169
|
+
if (this.currentHandoff?.handoffId === handoffId) {
|
|
170
|
+
this.currentHandoff = undefined
|
|
171
|
+
}
|
|
148
172
|
}
|
|
149
173
|
}
|
|
150
174
|
)
|
|
@@ -200,6 +224,60 @@ export class ConnectionService {
|
|
|
200
224
|
// disconnect, expiry) from the live provider — telemetry only, never changes
|
|
201
225
|
// connection behaviour. Torn down on disconnect.
|
|
202
226
|
this.attachLifecycle(connectionMode, wallet.id, network.namespace)
|
|
227
|
+
this.attachSecondaryAccountSync(
|
|
228
|
+
connectionMode,
|
|
229
|
+
wallet.id,
|
|
230
|
+
network.namespace
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Routing for the connect currently in flight, if any (see the field). */
|
|
235
|
+
getCurrentHandoff(): { handoffId: string; walletId: string } | undefined {
|
|
236
|
+
return this.currentHandoff
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Dispatch the mode-specific connector call. Extracted from `connect` so the
|
|
241
|
+
* handoff wrapper's body stays the handoffId bookkeeping alone.
|
|
242
|
+
*/
|
|
243
|
+
private async runConnect(
|
|
244
|
+
connectionMode: ConnectionMode,
|
|
245
|
+
connector: Connector,
|
|
246
|
+
network: Network,
|
|
247
|
+
provider: SupportedProvider,
|
|
248
|
+
signal: AbortSignal
|
|
249
|
+
): Promise<ConnectorResult> {
|
|
250
|
+
// Race the connector call against the AbortSignal so a mid-flight abort
|
|
251
|
+
// (e.g. user cancels the WalletConnect QR while the prompt is open)
|
|
252
|
+
// rejects the caller's await promptly. The underlying wallet prompt
|
|
253
|
+
// itself can't be cancelled, but the dapp UI is no longer blocked on it.
|
|
254
|
+
if (connectionMode === 'injected') {
|
|
255
|
+
return await raceAbort(
|
|
256
|
+
connector.connect(
|
|
257
|
+
network,
|
|
258
|
+
provider as
|
|
259
|
+
| ExtensionInjectedProvider
|
|
260
|
+
| IntegratedBrowserInjectedProvider
|
|
261
|
+
),
|
|
262
|
+
signal
|
|
263
|
+
)
|
|
264
|
+
} else if (
|
|
265
|
+
connectionMode === 'tonConnect' ||
|
|
266
|
+
connectionMode === 'walletConnect'
|
|
267
|
+
) {
|
|
268
|
+
return await raceAbort(
|
|
269
|
+
this.connectWithURIPoll(
|
|
270
|
+
connector,
|
|
271
|
+
network,
|
|
272
|
+
provider,
|
|
273
|
+
connectionMode,
|
|
274
|
+
signal
|
|
275
|
+
),
|
|
276
|
+
signal
|
|
277
|
+
)
|
|
278
|
+
} else {
|
|
279
|
+
throw new Error(`Unsupported connection mode: ${connectionMode}`)
|
|
280
|
+
}
|
|
203
281
|
}
|
|
204
282
|
|
|
205
283
|
/** Wire the active connector's lifecycle stream onto the bus (or signal absence). */
|
|
@@ -207,7 +285,7 @@ export class ConnectionService {
|
|
|
207
285
|
connectionMode: ConnectionMode,
|
|
208
286
|
walletId: string,
|
|
209
287
|
namespace: Namespace,
|
|
210
|
-
initialSwitch?: { chainId: NetworkId; at: number }
|
|
288
|
+
initialSwitch?: { chainId: NetworkId; address?: string; at: number }
|
|
211
289
|
): void {
|
|
212
290
|
this.teardownLifecycle()
|
|
213
291
|
const connector = this.activeConnector
|
|
@@ -227,6 +305,16 @@ export class ConnectionService {
|
|
|
227
305
|
walletId
|
|
228
306
|
})
|
|
229
307
|
: undefined,
|
|
308
|
+
subscribeAccounts: connector?.onAccountsChanged
|
|
309
|
+
? listener =>
|
|
310
|
+
connector.onAccountsChanged!(listener, {
|
|
311
|
+
connectionMode,
|
|
312
|
+
namespace,
|
|
313
|
+
walletId
|
|
314
|
+
})
|
|
315
|
+
: undefined,
|
|
316
|
+
onAccountsChanged: accounts =>
|
|
317
|
+
this.handleAccountsChanged(accounts, namespace),
|
|
230
318
|
getRouting: () => {
|
|
231
319
|
const s = this.sessionManager.getSession()
|
|
232
320
|
return {
|
|
@@ -249,6 +337,71 @@ export class ConnectionService {
|
|
|
249
337
|
})
|
|
250
338
|
}
|
|
251
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Apply a REAL account-list change (from `Connector.onAccountsChanged`, via
|
|
342
|
+
* the lifecycle bridge's second stream) to the session. `namespace` is the
|
|
343
|
+
* namespace of the provider that reported the change — `activeAddress`
|
|
344
|
+
* only moves when that matches the session's currently active network; an
|
|
345
|
+
* empty list is a full, wallet-initiated disconnect.
|
|
346
|
+
*/
|
|
347
|
+
private handleAccountsChanged(
|
|
348
|
+
accounts: string[],
|
|
349
|
+
namespace: Namespace
|
|
350
|
+
): void {
|
|
351
|
+
if (accounts.length === 0) {
|
|
352
|
+
// Idempotency guard: this runs synchronously FROM INSIDE the
|
|
353
|
+
// connector's own accountsChanged dispatch — finishDisconnect() below
|
|
354
|
+
// tears down (among other things) the very subscription that is
|
|
355
|
+
// currently delivering this callback. If a wallet double-fires the
|
|
356
|
+
// empty-array disconnect signal (or one slips through before teardown
|
|
357
|
+
// takes effect), `activeConnector` is already null from the first call
|
|
358
|
+
// — skip re-clearing an already-cleared session and re-emitting
|
|
359
|
+
// disconnected/sessionChanged a second time. Scoped to THIS wallet-
|
|
360
|
+
// initiated path only; the public disconnect() below still always
|
|
361
|
+
// runs finishDisconnect() defensively, even with no active connector.
|
|
362
|
+
if (this.activeConnector === null) return
|
|
363
|
+
// The wallet ended the session on its own side, but the connector's
|
|
364
|
+
// OWN local state (e.g. EthereumWalletService's cached account/
|
|
365
|
+
// provider/uuid) is never cleared by finishDisconnect() below — it
|
|
366
|
+
// only clears core's sessionManager. Left uncleared, a later connect
|
|
367
|
+
// to a DIFFERENT wallet reads that stale cached account via
|
|
368
|
+
// checkExistingConnections() and never rebinds to the new wallet's
|
|
369
|
+
// provider/uuid, silently keeping the old wallet's address live under
|
|
370
|
+
// the new wallet's session. disconnect() is fire-and-forget and
|
|
371
|
+
// error-swallowed here: for injected connectors it's local-only
|
|
372
|
+
// cleanup (safe to call any number of times), and for protocol-based
|
|
373
|
+
// connectors that might reject on an already-closed session, there is
|
|
374
|
+
// no caller to propagate that error to anyway.
|
|
375
|
+
this.activeConnector?.disconnect?.().catch(() => {})
|
|
376
|
+
this.finishDisconnect()
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
const newAddress = accounts[0]!
|
|
380
|
+
const session = this.sessionManager.getSession()
|
|
381
|
+
const availableAddresses = session.availableAddresses.map(addr => {
|
|
382
|
+
const network = this.networks.find(n => n.id === addr.networkId)
|
|
383
|
+
return network?.namespace === namespace
|
|
384
|
+
? { ...addr, address: newAddress }
|
|
385
|
+
: addr
|
|
386
|
+
})
|
|
387
|
+
const activeAddress =
|
|
388
|
+
session.activeNetwork?.namespace === namespace
|
|
389
|
+
? newAddress
|
|
390
|
+
: session.activeAddress
|
|
391
|
+
this.sessionManager.updateSession({ activeAddress, availableAddresses })
|
|
392
|
+
this.eventManager.emit('sessionChanged', {
|
|
393
|
+
session: this.sessionManager.getSession()
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
// A single wallet account drives ALL its namespaces (e.g. MetaMask's Solana
|
|
397
|
+
// Snap derives its key from the selected EVM account). The wallet only fired
|
|
398
|
+
// an accountsChanged for THIS namespace; refresh the live account of every
|
|
399
|
+
// other connected namespace so the session doesn't serve a stale sibling
|
|
400
|
+
// address. Fire-and-forget: we're inside the wallet's synchronous event
|
|
401
|
+
// dispatch and must not await.
|
|
402
|
+
void this.refreshSiblingNamespaces(namespace)
|
|
403
|
+
}
|
|
404
|
+
|
|
252
405
|
/**
|
|
253
406
|
* Remove the active provider-lifecycle subscription (EIP-1193 `.on` / wallet-
|
|
254
407
|
* adapter / SignClient listeners) WITHOUT disconnecting the wallet. Idempotent.
|
|
@@ -259,6 +412,13 @@ export class ConnectionService {
|
|
|
259
412
|
teardownLifecycle(): void {
|
|
260
413
|
this.lifecycleTeardown?.()
|
|
261
414
|
this.lifecycleTeardown = undefined
|
|
415
|
+
for (const teardown of this.secondaryAccountSyncTeardowns) teardown()
|
|
416
|
+
this.secondaryAccountSyncTeardowns = []
|
|
417
|
+
// Supersede any in-flight sibling-namespace refresh (see
|
|
418
|
+
// `refreshSiblingNamespaces`) — a still-polling refresh must not write a
|
|
419
|
+
// now-stale address after teardown/disconnect. `finishDisconnect()` calls
|
|
420
|
+
// this too, so one bump here covers both paths.
|
|
421
|
+
this.siblingRefreshGeneration++
|
|
262
422
|
}
|
|
263
423
|
|
|
264
424
|
/**
|
|
@@ -283,11 +443,286 @@ export class ConnectionService {
|
|
|
283
443
|
session.connectionMode,
|
|
284
444
|
session.activeWallet.id,
|
|
285
445
|
session.activeNetwork.namespace,
|
|
286
|
-
// The reattach follows a JUST-completed switch to the now-active chain
|
|
287
|
-
// arm echo suppression for
|
|
288
|
-
// for a wallet-initiated change (one-shot, same as
|
|
289
|
-
|
|
446
|
+
// The reattach follows a JUST-completed switch to the now-active chain
|
|
447
|
+
// (and address) — arm echo suppression for both so the new provider's
|
|
448
|
+
// echo isn't mistaken for a wallet-initiated change (one-shot, same as
|
|
449
|
+
// bus-armed switches).
|
|
450
|
+
{
|
|
451
|
+
chainId: session.activeNetwork.id,
|
|
452
|
+
...(session.activeAddress && { address: session.activeAddress }),
|
|
453
|
+
at: Date.now()
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
// Re-derive the secondary set against the NOW-active namespace — a
|
|
457
|
+
// cross-namespace switch swaps which namespace is "secondary" (the
|
|
458
|
+
// attachLifecycle above already tore the previous set down via
|
|
459
|
+
// teardownLifecycle).
|
|
460
|
+
this.attachSecondaryAccountSync(
|
|
461
|
+
session.connectionMode,
|
|
462
|
+
session.activeWallet.id,
|
|
463
|
+
session.activeNetwork.namespace
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Attach account-sync for every OTHER namespace the session's
|
|
469
|
+
* `availableAddresses` span. `attachLifecycle` observes only the ACTIVE
|
|
470
|
+
* network's namespace, but the cross-namespace gather seeds addresses from
|
|
471
|
+
* other namespaces' live surfaces at connect time — without their own
|
|
472
|
+
* subscription, a wallet-side account switch on such a namespace is never
|
|
473
|
+
* observed and the session serves the connect-time addresses forever (e.g.
|
|
474
|
+
* a Solana-active MetaMask session whose eip155 addresses never follow the
|
|
475
|
+
* extension's account switch).
|
|
476
|
+
*
|
|
477
|
+
* Accounts-sync ONLY, no lifecycle-telemetry stream: the active namespace
|
|
478
|
+
* stays the single source of wallet* telemetry and of the wallet-initiated
|
|
479
|
+
* disconnect signal.
|
|
480
|
+
*/
|
|
481
|
+
private attachSecondaryAccountSync(
|
|
482
|
+
connectionMode: ConnectionMode,
|
|
483
|
+
walletId: string,
|
|
484
|
+
activeNamespace: Namespace
|
|
485
|
+
): void {
|
|
486
|
+
const connector = this.activeConnector
|
|
487
|
+
if (!connector?.onAccountsChanged) return
|
|
488
|
+
|
|
489
|
+
const session = this.sessionManager.getSession()
|
|
490
|
+
const secondaryNamespaces = new Set<Namespace>()
|
|
491
|
+
for (const addr of session.availableAddresses) {
|
|
492
|
+
const namespace = this.networks.find(
|
|
493
|
+
n => n.id === addr.networkId
|
|
494
|
+
)?.namespace
|
|
495
|
+
if (namespace && namespace !== activeNamespace) {
|
|
496
|
+
secondaryNamespaces.add(namespace)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const runtime =
|
|
501
|
+
(globalThis as { UWCBridgeChildInitialized?: boolean })
|
|
502
|
+
.UWCBridgeChildInitialized === true
|
|
503
|
+
? ('iframe' as const)
|
|
504
|
+
: ('direct' as const)
|
|
505
|
+
|
|
506
|
+
for (const namespace of secondaryNamespaces) {
|
|
507
|
+
this.secondaryAccountSyncTeardowns.push(
|
|
508
|
+
attachLifecycle({
|
|
509
|
+
eventManager: this.eventManager,
|
|
510
|
+
subscribe: undefined,
|
|
511
|
+
subscribeAccounts: listener =>
|
|
512
|
+
connector.onAccountsChanged!(listener, {
|
|
513
|
+
connectionMode,
|
|
514
|
+
namespace,
|
|
515
|
+
walletId
|
|
516
|
+
}),
|
|
517
|
+
onAccountsChanged: accounts => {
|
|
518
|
+
// An empty list from a SECONDARY namespace is USUALLY just that
|
|
519
|
+
// namespace's own de-auth, not the whole session — so we don't
|
|
520
|
+
// clobber the session on a partial change. BUT some wallets emit a
|
|
521
|
+
// disconnect ONLY on a secondary namespace: e.g. MetaMask (EVM +
|
|
522
|
+
// Solana Snap) fires `accountsChanged([])` on its eip155 leg but its
|
|
523
|
+
// Snap-derived active (solana) namespace emits nothing on a full
|
|
524
|
+
// wallet disconnect. Relying on the active namespace's stream alone
|
|
525
|
+
// then never sees the disconnect. So on a secondary empty, verify
|
|
526
|
+
// whether the wallet is FULLY gone (active namespace has no live
|
|
527
|
+
// account either) and, if so, clear the session.
|
|
528
|
+
if (accounts.length === 0) {
|
|
529
|
+
void this.clearSessionIfWalletFullyDisconnected(
|
|
530
|
+
activeNamespace,
|
|
531
|
+
connectionMode,
|
|
532
|
+
walletId
|
|
533
|
+
)
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
// Surface the secondary-namespace switch for observability. The
|
|
537
|
+
// ACTIVE namespace's switch already emits `walletAccountChanged` via
|
|
538
|
+
// the lifecycle bridge; secondary namespaces have accounts-sync only
|
|
539
|
+
// (no lifecycle stream), so emit it here for parity → `uwcObserver`
|
|
540
|
+
// forwards it to RUM as `uwc.walletAccountChanged`. Secondary-only,
|
|
541
|
+
// so it never double-counts the active namespace's emit.
|
|
542
|
+
this.eventManager.emit('walletAccountChanged', {
|
|
543
|
+
connectionMode,
|
|
544
|
+
walletId,
|
|
545
|
+
namespace
|
|
546
|
+
})
|
|
547
|
+
this.handleAccountsChanged(accounts, namespace)
|
|
548
|
+
},
|
|
549
|
+
getRouting: () => ({ connectionMode, walletId, namespace }),
|
|
550
|
+
connectionMode,
|
|
551
|
+
walletId,
|
|
552
|
+
namespace,
|
|
553
|
+
runtime
|
|
554
|
+
})
|
|
555
|
+
)
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* A SECONDARY namespace just reported an empty account list. Determine whether
|
|
561
|
+
* this is a full wallet-initiated disconnect (vs. only that namespace losing
|
|
562
|
+
* authorization) by reading the ACTIVE namespace's LIVE account: if it's also
|
|
563
|
+
* gone, the whole wallet disconnected — clear the session via the active
|
|
564
|
+
* namespace's empty-list path. If the active namespace still has an account
|
|
565
|
+
* (a genuine partial change) or we can't tell, keep the conservative no-op.
|
|
566
|
+
*
|
|
567
|
+
* Motivating case: MetaMask's Solana-Snap active namespace emits no disconnect
|
|
568
|
+
* signal, while its eip155 (secondary) leg does — so the secondary empty is the
|
|
569
|
+
* only signal the wallet is gone.
|
|
570
|
+
*/
|
|
571
|
+
private async clearSessionIfWalletFullyDisconnected(
|
|
572
|
+
activeNamespace: Namespace,
|
|
573
|
+
connectionMode: ConnectionMode,
|
|
574
|
+
walletId: string
|
|
575
|
+
): Promise<void> {
|
|
576
|
+
const connector = this.activeConnector
|
|
577
|
+
// Already torn down, or the connector can't report a live account — keep the
|
|
578
|
+
// conservative no-op (never risk a spurious disconnect).
|
|
579
|
+
if (!connector?.getLiveAccount) return
|
|
580
|
+
// The active namespace must still be the one we captured; a switch/teardown
|
|
581
|
+
// in flight supersedes this check.
|
|
582
|
+
if (
|
|
583
|
+
this.sessionManager.getSession().activeNetwork?.namespace !==
|
|
584
|
+
activeNamespace
|
|
585
|
+
) {
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
let liveActiveAccount: string | null
|
|
589
|
+
try {
|
|
590
|
+
liveActiveAccount = await connector.getLiveAccount(activeNamespace)
|
|
591
|
+
} catch {
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
// Active namespace still connected → genuine partial change → leave session.
|
|
595
|
+
if (liveActiveAccount) return
|
|
596
|
+
// Re-check the connector is still live after the await (guards a race with a
|
|
597
|
+
// concurrent teardown/disconnect that already cleared it).
|
|
598
|
+
if (this.activeConnector === null) return
|
|
599
|
+
// Surface the wallet-initiated disconnect for observability BEFORE clearing:
|
|
600
|
+
// the active namespace here emits no disconnect of its own (that's the whole
|
|
601
|
+
// reason we detect it from a secondary namespace), so without this the
|
|
602
|
+
// `walletDisconnected` telemetry the observer forwards to RUM would never
|
|
603
|
+
// fire for these wallets. Mirrors the lifecycle-bridge's emit for wallets
|
|
604
|
+
// whose active namespace DOES signal a disconnect.
|
|
605
|
+
this.eventManager.emit('walletDisconnected', {
|
|
606
|
+
connectionMode,
|
|
607
|
+
walletId,
|
|
608
|
+
namespace: activeNamespace
|
|
609
|
+
})
|
|
610
|
+
// Whole wallet is gone → run the wallet-initiated disconnect path.
|
|
611
|
+
this.handleAccountsChanged([], activeNamespace)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* After a real account switch on `changedNamespace`, re-read the LIVE account
|
|
616
|
+
* of every OTHER connected namespace and fold any that changed into the
|
|
617
|
+
* session in ONE update. Sibling namespaces (e.g. a MetaMask Solana Snap
|
|
618
|
+
* address derived from the EVM account) change with the underlying account but
|
|
619
|
+
* emit no `accountsChanged`, and their live read can LAG the firing namespace's
|
|
620
|
+
* event while the wallet re-derives — so poll each sibling up to
|
|
621
|
+
* `SIBLING_REFRESH_MAX_ATTEMPTS` times, `SIBLING_REFRESH_DELAY_MS` apart,
|
|
622
|
+
* stopping a sibling the moment its live address differs from the stored one.
|
|
623
|
+
*
|
|
624
|
+
* Loop-safe: writes addresses DIRECTLY via the session manager (never
|
|
625
|
+
* re-dispatches a per-namespace accountsChanged), and only emits when at least
|
|
626
|
+
* one sibling actually changed — so refreshing B here cannot re-enter this for
|
|
627
|
+
* A and recurse. Generation-guarded so a newer switch / teardown / disconnect
|
|
628
|
+
* supersedes an older still-polling refresh before it writes.
|
|
629
|
+
*/
|
|
630
|
+
private async refreshSiblingNamespaces(
|
|
631
|
+
changedNamespace: Namespace
|
|
632
|
+
): Promise<void> {
|
|
633
|
+
const connector = this.activeConnector
|
|
634
|
+
if (!connector?.getLiveAccount) return
|
|
635
|
+
const gen = ++this.siblingRefreshGeneration
|
|
636
|
+
|
|
637
|
+
const namespaceForNetworkId = (
|
|
638
|
+
networkId: NetworkId
|
|
639
|
+
): Namespace | undefined =>
|
|
640
|
+
this.networks.find(n => n.id === networkId)?.namespace
|
|
641
|
+
|
|
642
|
+
// Sibling = every connected namespace except the one that just fired.
|
|
643
|
+
const initial = this.sessionManager.getSession()
|
|
644
|
+
const siblings = new Set<Namespace>()
|
|
645
|
+
for (const addr of initial.availableAddresses) {
|
|
646
|
+
const ns = namespaceForNetworkId(addr.networkId)
|
|
647
|
+
if (ns && ns !== changedNamespace) siblings.add(ns)
|
|
648
|
+
}
|
|
649
|
+
if (siblings.size === 0) return
|
|
650
|
+
|
|
651
|
+
const equal = (ns: Namespace, a: string, b: string): boolean =>
|
|
652
|
+
ns === 'eip155' ? a.toLowerCase() === b.toLowerCase() : a === b
|
|
653
|
+
|
|
654
|
+
const storedFor = (ns: Namespace): string | undefined => {
|
|
655
|
+
const s = this.sessionManager.getSession()
|
|
656
|
+
for (const addr of s.availableAddresses) {
|
|
657
|
+
if (namespaceForNetworkId(addr.networkId) === ns) return addr.address
|
|
658
|
+
}
|
|
659
|
+
return undefined
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const resolved = new Map<Namespace, string>()
|
|
663
|
+
const pending = new Set(siblings)
|
|
664
|
+
|
|
665
|
+
for (
|
|
666
|
+
let attempt = 0;
|
|
667
|
+
attempt < SIBLING_REFRESH_MAX_ATTEMPTS && pending.size > 0;
|
|
668
|
+
attempt++
|
|
669
|
+
) {
|
|
670
|
+
// Superseded (newer switch), disconnected, or connector swapped → abandon.
|
|
671
|
+
if (
|
|
672
|
+
gen !== this.siblingRefreshGeneration ||
|
|
673
|
+
this.activeConnector !== connector
|
|
674
|
+
)
|
|
675
|
+
return
|
|
676
|
+
for (const ns of [...pending]) {
|
|
677
|
+
let live: string | null = null
|
|
678
|
+
try {
|
|
679
|
+
live = await connector.getLiveAccount!(ns)
|
|
680
|
+
} catch {
|
|
681
|
+
live = null
|
|
682
|
+
}
|
|
683
|
+
if (
|
|
684
|
+
gen !== this.siblingRefreshGeneration ||
|
|
685
|
+
this.activeConnector !== connector
|
|
686
|
+
)
|
|
687
|
+
return
|
|
688
|
+
if (!live) continue
|
|
689
|
+
const stored = storedFor(ns)
|
|
690
|
+
if (stored != null && !equal(ns, stored, live)) {
|
|
691
|
+
resolved.set(ns, live)
|
|
692
|
+
pending.delete(ns)
|
|
693
|
+
}
|
|
694
|
+
// stored == live (not yet re-derived) → keep polling until the cap.
|
|
695
|
+
}
|
|
696
|
+
if (pending.size === 0) break
|
|
697
|
+
await new Promise<void>(r => setTimeout(r, SIBLING_REFRESH_DELAY_MS))
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (resolved.size === 0) return
|
|
701
|
+
if (
|
|
702
|
+
gen !== this.siblingRefreshGeneration ||
|
|
703
|
+
this.activeConnector !== connector
|
|
290
704
|
)
|
|
705
|
+
return
|
|
706
|
+
|
|
707
|
+
const session = this.sessionManager.getSession()
|
|
708
|
+
const availableAddresses = session.availableAddresses.map(addr => {
|
|
709
|
+
const ns = namespaceForNetworkId(addr.networkId)
|
|
710
|
+
const next = ns ? resolved.get(ns) : undefined
|
|
711
|
+
return next ? { ...addr, address: next } : addr
|
|
712
|
+
})
|
|
713
|
+
// If the ACTIVE network's namespace was among the refreshed siblings, move
|
|
714
|
+
// activeAddress too (e.g. a Solana-active session whose Solana address just
|
|
715
|
+
// followed an EVM switch).
|
|
716
|
+
const activeNs = session.activeNetwork?.namespace
|
|
717
|
+
const activeAddress =
|
|
718
|
+
activeNs && resolved.has(activeNs)
|
|
719
|
+
? resolved.get(activeNs)!
|
|
720
|
+
: session.activeAddress
|
|
721
|
+
|
|
722
|
+
this.sessionManager.updateSession({ activeAddress, availableAddresses })
|
|
723
|
+
this.eventManager.emit('sessionChanged', {
|
|
724
|
+
session: this.sessionManager.getSession()
|
|
725
|
+
})
|
|
291
726
|
}
|
|
292
727
|
|
|
293
728
|
private async connectWithURIPoll(
|
|
@@ -486,14 +921,32 @@ export class ConnectionService {
|
|
|
486
921
|
await this.activeConnector.disconnect()
|
|
487
922
|
}
|
|
488
923
|
} finally {
|
|
489
|
-
this.
|
|
490
|
-
this.sessionManager.clearSession()
|
|
491
|
-
const session = this.sessionManager.getSession()
|
|
492
|
-
this.eventManager.emit('disconnected', undefined)
|
|
493
|
-
this.eventManager.emit('sessionChanged', { session })
|
|
924
|
+
this.finishDisconnect()
|
|
494
925
|
}
|
|
495
926
|
}
|
|
496
927
|
|
|
928
|
+
/**
|
|
929
|
+
* Clears core's own local state (session, active connector reference,
|
|
930
|
+
* lifecycle subscription) and emits the disconnect events. Does NOT itself
|
|
931
|
+
* call the connector's `disconnect()` — that happens in each CALLER,
|
|
932
|
+
* because the two callers need different semantics: the public
|
|
933
|
+
* `disconnect()` above awaits it (the caller may care whether the wallet
|
|
934
|
+
* side actually tore down); the wallet-initiated path in
|
|
935
|
+
* `handleAccountsChanged` fires it and swallows the result (the wallet
|
|
936
|
+
* already ended the session on its own side, but the connector's own
|
|
937
|
+
* LOCAL state — e.g. a cached account/uuid — still needs clearing, or a
|
|
938
|
+
* later connect to a different wallet reads it as stale and never
|
|
939
|
+
* rebinds).
|
|
940
|
+
*/
|
|
941
|
+
private finishDisconnect(): void {
|
|
942
|
+
this.teardownLifecycle()
|
|
943
|
+
this.activeConnector = null
|
|
944
|
+
this.sessionManager.clearSession()
|
|
945
|
+
const session = this.sessionManager.getSession()
|
|
946
|
+
this.eventManager.emit('disconnected', undefined)
|
|
947
|
+
this.eventManager.emit('sessionChanged', { session })
|
|
948
|
+
}
|
|
949
|
+
|
|
497
950
|
getConnectionURI(): string | undefined {
|
|
498
951
|
if (
|
|
499
952
|
this.activeConnector &&
|
|
@@ -234,4 +234,37 @@ describe('NetworkSwitchService', () => {
|
|
|
234
234
|
expect(events[0].data.operation).toBe('switchNetwork')
|
|
235
235
|
expect(events[0].data.chainId).toBe('eip155:137')
|
|
236
236
|
})
|
|
237
|
+
|
|
238
|
+
it('includes the new address on the emitted networkSwitched event', async () => {
|
|
239
|
+
sessionManager.updateSession({
|
|
240
|
+
connectionMode: 'injected',
|
|
241
|
+
activeWallet: {
|
|
242
|
+
id: 'metamask',
|
|
243
|
+
name: 'MetaMask',
|
|
244
|
+
metadata: {
|
|
245
|
+
icon: 'test',
|
|
246
|
+
downloadUrl: 'test',
|
|
247
|
+
supportedChains: ['eip155:1', 'eip155:137']
|
|
248
|
+
},
|
|
249
|
+
extensionInjectedProvider: {
|
|
250
|
+
supportedNetworkIds: ['eip155:1', 'eip155:137']
|
|
251
|
+
}
|
|
252
|
+
} as any
|
|
253
|
+
})
|
|
254
|
+
mockConnector.switchNetwork = vi.fn().mockResolvedValue({
|
|
255
|
+
address: '0xabc',
|
|
256
|
+
availableAddresses: [{ address: '0xabc', networkId: 'eip155:137' }]
|
|
257
|
+
})
|
|
258
|
+
const switched: Array<{ network: unknown; address?: string }> = []
|
|
259
|
+
eventManager.on('networkSwitched', d => switched.push(d))
|
|
260
|
+
|
|
261
|
+
await networkSwitchService.switchNetwork('eip155:137')
|
|
262
|
+
|
|
263
|
+
expect(switched).toEqual([
|
|
264
|
+
{
|
|
265
|
+
network: expect.objectContaining({ id: 'eip155:137' }),
|
|
266
|
+
address: '0xabc'
|
|
267
|
+
}
|
|
268
|
+
])
|
|
269
|
+
})
|
|
237
270
|
})
|
|
@@ -188,7 +188,10 @@ export class NetworkSwitchService {
|
|
|
188
188
|
})
|
|
189
189
|
})
|
|
190
190
|
|
|
191
|
-
this.eventManager.emit('networkSwitched', {
|
|
191
|
+
this.eventManager.emit('networkSwitched', {
|
|
192
|
+
network,
|
|
193
|
+
address: result.address
|
|
194
|
+
})
|
|
192
195
|
this.eventManager.emit('sessionChanged', {
|
|
193
196
|
session: this.sessionManager.getSession()
|
|
194
197
|
})
|
|
@@ -58,7 +58,10 @@ describe('SignatureService.signMessage', () => {
|
|
|
58
58
|
mockConnector = {
|
|
59
59
|
connect: vi.fn(),
|
|
60
60
|
disconnect: vi.fn(),
|
|
61
|
-
signMessage: vi.fn().mockResolvedValue(
|
|
61
|
+
signMessage: vi.fn().mockResolvedValue({
|
|
62
|
+
signature: { type: 'standard', signature: '0xsignature' },
|
|
63
|
+
address: '0x123'
|
|
64
|
+
}),
|
|
62
65
|
sendTransaction: vi.fn(),
|
|
63
66
|
switchNetwork: vi.fn()
|
|
64
67
|
}
|
|
@@ -168,7 +171,11 @@ describe('SignatureService.signMessage', () => {
|
|
|
168
171
|
|
|
169
172
|
const result = await signatureService.signMessage('test message')
|
|
170
173
|
|
|
171
|
-
|
|
174
|
+
// Core is a passthrough — the connector's full SignatureResult surfaces.
|
|
175
|
+
expect(result).toEqual({
|
|
176
|
+
signature: { type: 'standard', signature: '0xsignature' },
|
|
177
|
+
address: '0x123'
|
|
178
|
+
})
|
|
172
179
|
// The resolved session provider is threaded to the connector.
|
|
173
180
|
expect(mockConnector.signMessage).toHaveBeenCalledWith(
|
|
174
181
|
'test message',
|