@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.
Files changed (51) hide show
  1. package/dist/events.d.ts +46 -2
  2. package/dist/events.d.ts.map +1 -1
  3. package/dist/events.js +2 -0
  4. package/dist/events.js.map +1 -1
  5. package/dist/observability/instrument-handoff.d.ts +8 -1
  6. package/dist/observability/instrument-handoff.d.ts.map +1 -1
  7. package/dist/observability/instrument-handoff.js +9 -2
  8. package/dist/observability/instrument-handoff.js.map +1 -1
  9. package/dist/observability/lifecycle-bridge.d.ts +31 -13
  10. package/dist/observability/lifecycle-bridge.d.ts.map +1 -1
  11. package/dist/observability/lifecycle-bridge.js +194 -92
  12. package/dist/observability/lifecycle-bridge.js.map +1 -1
  13. package/dist/observability/telemetry.d.ts +15 -1
  14. package/dist/observability/telemetry.d.ts.map +1 -1
  15. package/dist/observability/telemetry.js +15 -0
  16. package/dist/observability/telemetry.js.map +1 -1
  17. package/dist/services/connection-service.d.ts +109 -0
  18. package/dist/services/connection-service.d.ts.map +1 -1
  19. package/dist/services/connection-service.js +407 -22
  20. package/dist/services/connection-service.js.map +1 -1
  21. package/dist/services/network-switch-service.d.ts.map +1 -1
  22. package/dist/services/network-switch-service.js +4 -1
  23. package/dist/services/network-switch-service.js.map +1 -1
  24. package/dist/services/signature-service.d.ts +4 -3
  25. package/dist/services/signature-service.d.ts.map +1 -1
  26. package/dist/services/signature-service.js +2 -1
  27. package/dist/services/signature-service.js.map +1 -1
  28. package/dist/universal-wallet-connector.d.ts +15 -3
  29. package/dist/universal-wallet-connector.d.ts.map +1 -1
  30. package/dist/universal-wallet-connector.js +31 -2
  31. package/dist/universal-wallet-connector.js.map +1 -1
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.d.ts.map +1 -1
  34. package/dist/version.js +1 -1
  35. package/dist/version.js.map +1 -1
  36. package/package.json +5 -5
  37. package/src/events.ts +51 -3
  38. package/src/observability/instrument-handoff.ts +8 -2
  39. package/src/observability/lifecycle-bridge.test.ts +316 -0
  40. package/src/observability/lifecycle-bridge.ts +240 -99
  41. package/src/observability/telemetry.ts +32 -1
  42. package/src/services/connection-service.reentrancy.test.ts +87 -0
  43. package/src/services/connection-service.test.ts +798 -1
  44. package/src/services/connection-service.ts +492 -39
  45. package/src/services/network-switch-service.test.ts +33 -0
  46. package/src/services/network-switch-service.ts +4 -1
  47. package/src/services/signature-service.test.ts +9 -2
  48. package/src/services/signature-service.ts +4 -3
  49. package/src/universal-wallet-connector.observability.test.ts +126 -3
  50. package/src/universal-wallet-connector.ts +43 -4
  51. 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
- // Race the connector call against the AbortSignal so a mid-flight abort
119
- // (e.g. user cancels the WalletConnect QR while the prompt is open)
120
- // rejects the caller's await promptly. The underlying wallet prompt
121
- // itself can't be cancelled, but the dapp UI is no longer blocked on it.
122
- if (connectionMode === 'injected') {
123
- return await raceAbort(
124
- connector.connect(
125
- network,
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
- } else {
147
- throw new Error(`Unsupported connection mode: ${connectionMode}`)
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 it so the new provider's echo isn't mistaken
288
- // for a wallet-initiated change (one-shot, same as bus-armed switches).
289
- { chainId: session.activeNetwork.id, at: Date.now() }
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.activeConnector = null
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', { network })
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('0xsignature'),
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
- expect(result).toBe('0xsignature')
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',