@meshconnect/uwc-core 1.2.3-snapshot.bd9aae7 → 1.2.3-snapshot.dcfdbbf

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 (44) hide show
  1. package/dist/events.d.ts +5 -42
  2. package/dist/events.d.ts.map +1 -1
  3. package/dist/events.js +0 -4
  4. package/dist/events.js.map +1 -1
  5. package/dist/observability/focus-tracker.d.ts +12 -25
  6. package/dist/observability/focus-tracker.d.ts.map +1 -1
  7. package/dist/observability/focus-tracker.js +19 -42
  8. package/dist/observability/focus-tracker.js.map +1 -1
  9. package/dist/observability/instrument-handoff.d.ts.map +1 -1
  10. package/dist/observability/instrument-handoff.js +6 -24
  11. package/dist/observability/instrument-handoff.js.map +1 -1
  12. package/dist/observability/lifecycle-bridge.d.ts +31 -13
  13. package/dist/observability/lifecycle-bridge.d.ts.map +1 -1
  14. package/dist/observability/lifecycle-bridge.js +167 -92
  15. package/dist/observability/lifecycle-bridge.js.map +1 -1
  16. package/dist/observability/telemetry.d.ts +0 -8
  17. package/dist/observability/telemetry.d.ts.map +1 -1
  18. package/dist/observability/telemetry.js +5 -49
  19. package/dist/observability/telemetry.js.map +1 -1
  20. package/dist/services/connection-service.d.ts +43 -0
  21. package/dist/services/connection-service.d.ts.map +1 -1
  22. package/dist/services/connection-service.js +163 -9
  23. package/dist/services/connection-service.js.map +1 -1
  24. package/dist/services/network-switch-service.d.ts.map +1 -1
  25. package/dist/services/network-switch-service.js +4 -1
  26. package/dist/services/network-switch-service.js.map +1 -1
  27. package/dist/universal-wallet-connector.d.ts.map +1 -1
  28. package/dist/universal-wallet-connector.js +1 -7
  29. package/dist/universal-wallet-connector.js.map +1 -1
  30. package/package.json +5 -5
  31. package/src/events.ts +6 -50
  32. package/src/observability/focus-tracker.test.ts +8 -60
  33. package/src/observability/focus-tracker.ts +23 -59
  34. package/src/observability/instrument-handoff.test.ts +0 -42
  35. package/src/observability/instrument-handoff.ts +6 -26
  36. package/src/observability/lifecycle-bridge.test.ts +197 -0
  37. package/src/observability/lifecycle-bridge.ts +211 -99
  38. package/src/observability/telemetry.test.ts +0 -35
  39. package/src/observability/telemetry.ts +6 -58
  40. package/src/services/connection-service.test.ts +272 -0
  41. package/src/services/connection-service.ts +190 -10
  42. package/src/services/network-switch-service.test.ts +33 -0
  43. package/src/services/network-switch-service.ts +4 -1
  44. package/src/universal-wallet-connector.ts +1 -11
@@ -863,5 +863,277 @@ 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
+ })
866
1138
  })
867
1139
  })
@@ -26,6 +26,12 @@ export class ConnectionService {
26
26
  private activeConnector: Connector | null = null
27
27
  /** Tears down the post-connect lifecycle subscription; set while connected. */
28
28
  private lifecycleTeardown: (() => void) | undefined
29
+ /**
30
+ * Teardowns for the accounts-sync attachments of the session's SECONDARY
31
+ * namespaces (see `attachSecondaryAccountSync`); set while connected,
32
+ * cleared together with `lifecycleTeardown`.
33
+ */
34
+ private secondaryAccountSyncTeardowns: Array<() => void> = []
29
35
  /**
30
36
  * Abort handle for the CURRENT in-flight connect attempt. A re-entrant
31
37
  * connect() aborts this before starting, so a superseded attempt's URI poll
@@ -200,6 +206,11 @@ export class ConnectionService {
200
206
  // disconnect, expiry) from the live provider — telemetry only, never changes
201
207
  // connection behaviour. Torn down on disconnect.
202
208
  this.attachLifecycle(connectionMode, wallet.id, network.namespace)
209
+ this.attachSecondaryAccountSync(
210
+ connectionMode,
211
+ wallet.id,
212
+ network.namespace
213
+ )
203
214
  }
204
215
 
205
216
  /** Wire the active connector's lifecycle stream onto the bus (or signal absence). */
@@ -207,7 +218,7 @@ export class ConnectionService {
207
218
  connectionMode: ConnectionMode,
208
219
  walletId: string,
209
220
  namespace: Namespace,
210
- initialSwitch?: { chainId: NetworkId; at: number }
221
+ initialSwitch?: { chainId: NetworkId; address?: string; at: number }
211
222
  ): void {
212
223
  this.teardownLifecycle()
213
224
  const connector = this.activeConnector
@@ -227,6 +238,16 @@ export class ConnectionService {
227
238
  walletId
228
239
  })
229
240
  : undefined,
241
+ subscribeAccounts: connector?.onAccountsChanged
242
+ ? listener =>
243
+ connector.onAccountsChanged!(listener, {
244
+ connectionMode,
245
+ namespace,
246
+ walletId
247
+ })
248
+ : undefined,
249
+ onAccountsChanged: accounts =>
250
+ this.handleAccountsChanged(accounts, namespace),
230
251
  getRouting: () => {
231
252
  const s = this.sessionManager.getSession()
232
253
  return {
@@ -249,6 +270,63 @@ export class ConnectionService {
249
270
  })
250
271
  }
251
272
 
273
+ /**
274
+ * Apply a REAL account-list change (from `Connector.onAccountsChanged`, via
275
+ * the lifecycle bridge's second stream) to the session. `namespace` is the
276
+ * namespace of the provider that reported the change — `activeAddress`
277
+ * only moves when that matches the session's currently active network; an
278
+ * empty list is a full, wallet-initiated disconnect.
279
+ */
280
+ private handleAccountsChanged(
281
+ accounts: string[],
282
+ namespace: Namespace
283
+ ): void {
284
+ if (accounts.length === 0) {
285
+ // Idempotency guard: this runs synchronously FROM INSIDE the
286
+ // connector's own accountsChanged dispatch — finishDisconnect() below
287
+ // tears down (among other things) the very subscription that is
288
+ // currently delivering this callback. If a wallet double-fires the
289
+ // empty-array disconnect signal (or one slips through before teardown
290
+ // takes effect), `activeConnector` is already null from the first call
291
+ // — skip re-clearing an already-cleared session and re-emitting
292
+ // disconnected/sessionChanged a second time. Scoped to THIS wallet-
293
+ // initiated path only; the public disconnect() below still always
294
+ // runs finishDisconnect() defensively, even with no active connector.
295
+ if (this.activeConnector === null) return
296
+ // The wallet ended the session on its own side, but the connector's
297
+ // OWN local state (e.g. EthereumWalletService's cached account/
298
+ // provider/uuid) is never cleared by finishDisconnect() below — it
299
+ // only clears core's sessionManager. Left uncleared, a later connect
300
+ // to a DIFFERENT wallet reads that stale cached account via
301
+ // checkExistingConnections() and never rebinds to the new wallet's
302
+ // provider/uuid, silently keeping the old wallet's address live under
303
+ // the new wallet's session. disconnect() is fire-and-forget and
304
+ // error-swallowed here: for injected connectors it's local-only
305
+ // cleanup (safe to call any number of times), and for protocol-based
306
+ // connectors that might reject on an already-closed session, there is
307
+ // no caller to propagate that error to anyway.
308
+ this.activeConnector?.disconnect?.().catch(() => {})
309
+ this.finishDisconnect()
310
+ return
311
+ }
312
+ const newAddress = accounts[0]!
313
+ const session = this.sessionManager.getSession()
314
+ const availableAddresses = session.availableAddresses.map(addr => {
315
+ const network = this.networks.find(n => n.id === addr.networkId)
316
+ return network?.namespace === namespace
317
+ ? { ...addr, address: newAddress }
318
+ : addr
319
+ })
320
+ const activeAddress =
321
+ session.activeNetwork?.namespace === namespace
322
+ ? newAddress
323
+ : session.activeAddress
324
+ this.sessionManager.updateSession({ activeAddress, availableAddresses })
325
+ this.eventManager.emit('sessionChanged', {
326
+ session: this.sessionManager.getSession()
327
+ })
328
+ }
329
+
252
330
  /**
253
331
  * Remove the active provider-lifecycle subscription (EIP-1193 `.on` / wallet-
254
332
  * adapter / SignClient listeners) WITHOUT disconnecting the wallet. Idempotent.
@@ -259,6 +337,8 @@ export class ConnectionService {
259
337
  teardownLifecycle(): void {
260
338
  this.lifecycleTeardown?.()
261
339
  this.lifecycleTeardown = undefined
340
+ for (const teardown of this.secondaryAccountSyncTeardowns) teardown()
341
+ this.secondaryAccountSyncTeardowns = []
262
342
  }
263
343
 
264
344
  /**
@@ -283,11 +363,93 @@ export class ConnectionService {
283
363
  session.connectionMode,
284
364
  session.activeWallet.id,
285
365
  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() }
366
+ // The reattach follows a JUST-completed switch to the now-active chain
367
+ // (and address) — arm echo suppression for both so the new provider's
368
+ // echo isn't mistaken for a wallet-initiated change (one-shot, same as
369
+ // bus-armed switches).
370
+ {
371
+ chainId: session.activeNetwork.id,
372
+ ...(session.activeAddress && { address: session.activeAddress }),
373
+ at: Date.now()
374
+ }
290
375
  )
376
+ // Re-derive the secondary set against the NOW-active namespace — a
377
+ // cross-namespace switch swaps which namespace is "secondary" (the
378
+ // attachLifecycle above already tore the previous set down via
379
+ // teardownLifecycle).
380
+ this.attachSecondaryAccountSync(
381
+ session.connectionMode,
382
+ session.activeWallet.id,
383
+ session.activeNetwork.namespace
384
+ )
385
+ }
386
+
387
+ /**
388
+ * Attach account-sync for every OTHER namespace the session's
389
+ * `availableAddresses` span. `attachLifecycle` observes only the ACTIVE
390
+ * network's namespace, but the cross-namespace gather seeds addresses from
391
+ * other namespaces' live surfaces at connect time — without their own
392
+ * subscription, a wallet-side account switch on such a namespace is never
393
+ * observed and the session serves the connect-time addresses forever (e.g.
394
+ * a Solana-active MetaMask session whose eip155 addresses never follow the
395
+ * extension's account switch).
396
+ *
397
+ * Accounts-sync ONLY, no lifecycle-telemetry stream: the active namespace
398
+ * stays the single source of wallet* telemetry and of the wallet-initiated
399
+ * disconnect signal.
400
+ */
401
+ private attachSecondaryAccountSync(
402
+ connectionMode: ConnectionMode,
403
+ walletId: string,
404
+ activeNamespace: Namespace
405
+ ): void {
406
+ const connector = this.activeConnector
407
+ if (!connector?.onAccountsChanged) return
408
+
409
+ const session = this.sessionManager.getSession()
410
+ const secondaryNamespaces = new Set<Namespace>()
411
+ for (const addr of session.availableAddresses) {
412
+ const namespace = this.networks.find(
413
+ n => n.id === addr.networkId
414
+ )?.namespace
415
+ if (namespace && namespace !== activeNamespace) {
416
+ secondaryNamespaces.add(namespace)
417
+ }
418
+ }
419
+
420
+ const runtime =
421
+ (globalThis as { UWCBridgeChildInitialized?: boolean })
422
+ .UWCBridgeChildInitialized === true
423
+ ? ('iframe' as const)
424
+ : ('direct' as const)
425
+
426
+ for (const namespace of secondaryNamespaces) {
427
+ this.secondaryAccountSyncTeardowns.push(
428
+ attachLifecycle({
429
+ eventManager: this.eventManager,
430
+ subscribe: undefined,
431
+ subscribeAccounts: listener =>
432
+ connector.onAccountsChanged!(listener, {
433
+ connectionMode,
434
+ namespace,
435
+ walletId
436
+ }),
437
+ onAccountsChanged: accounts => {
438
+ // An empty list from a SECONDARY namespace means that namespace's
439
+ // own authorization ended — NOT the session. Only the active
440
+ // namespace's stream may signal the full wallet-initiated
441
+ // disconnect (handleAccountsChanged's empty-list path).
442
+ if (accounts.length === 0) return
443
+ this.handleAccountsChanged(accounts, namespace)
444
+ },
445
+ getRouting: () => ({ connectionMode, walletId, namespace }),
446
+ connectionMode,
447
+ walletId,
448
+ namespace,
449
+ runtime
450
+ })
451
+ )
452
+ }
291
453
  }
292
454
 
293
455
  private async connectWithURIPoll(
@@ -486,14 +648,32 @@ export class ConnectionService {
486
648
  await this.activeConnector.disconnect()
487
649
  }
488
650
  } 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 })
651
+ this.finishDisconnect()
494
652
  }
495
653
  }
496
654
 
655
+ /**
656
+ * Clears core's own local state (session, active connector reference,
657
+ * lifecycle subscription) and emits the disconnect events. Does NOT itself
658
+ * call the connector's `disconnect()` — that happens in each CALLER,
659
+ * because the two callers need different semantics: the public
660
+ * `disconnect()` above awaits it (the caller may care whether the wallet
661
+ * side actually tore down); the wallet-initiated path in
662
+ * `handleAccountsChanged` fires it and swallows the result (the wallet
663
+ * already ended the session on its own side, but the connector's own
664
+ * LOCAL state — e.g. a cached account/uuid — still needs clearing, or a
665
+ * later connect to a different wallet reads it as stale and never
666
+ * rebinds).
667
+ */
668
+ private finishDisconnect(): void {
669
+ this.teardownLifecycle()
670
+ this.activeConnector = null
671
+ this.sessionManager.clearSession()
672
+ const session = this.sessionManager.getSession()
673
+ this.eventManager.emit('disconnected', undefined)
674
+ this.eventManager.emit('sessionChanged', { session })
675
+ }
676
+
497
677
  getConnectionURI(): string | undefined {
498
678
  if (
499
679
  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
  })
@@ -325,17 +325,7 @@ export class UniversalWalletConnector {
325
325
  (error: unknown) =>
326
326
  this.eventManager.emit('walletConnectInitError', {
327
327
  error: toWalletError(error)
328
- }),
329
- // Relay transport dropped — capture the FACT (+ scrubbed reason if any) so a
330
- // socket death during a wallet deep-link is visible, not silent (CARE-263).
331
- (detail: unknown) =>
332
- this.eventManager.emit(
333
- 'relayDisconnected',
334
- detail ? { error: toWalletError(detail) } : {}
335
- ),
336
- // Relay transport recovered — pair with `relayDisconnected` (same
337
- // sdkSessionId) to measure the dead window.
338
- () => this.eventManager.emit('relayReconnected', undefined)
328
+ })
339
329
  )
340
330
  )
341
331
  }