@meshconnect/uwc-core 1.1.0 → 1.1.1-snapshot.2e0528b

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.
@@ -29,11 +29,23 @@ export interface AttachLifecycleOptions {
29
29
  * The active connector's `onLifecycleEvent` (already bound). `undefined` when the
30
30
  * connector can't observe lifecycle at all; the value it returns is `undefined`
31
31
  * when THIS session can't be observed (e.g. no live `.on`). Either way the
32
- * bridge emits `lifecycleTelemetryUnavailable` and becomes a no-op.
32
+ * bridge emits `lifecycleTelemetryUnavailable` and becomes a no-op FOR THIS
33
+ * STREAM — `subscribeAccounts` below is attached independently.
33
34
  */
34
35
  subscribe:
35
36
  | ((l: (e: WalletLifecycleEvent) => void) => (() => void) | undefined)
36
37
  | undefined
38
+ /**
39
+ * The active connector's `onAccountsChanged` (already bound), carrying the
40
+ * REAL account list — a separate concern from `subscribe` above (some
41
+ * connectors, e.g. Tron today, implement one without the other). Absent
42
+ * entirely when the connector doesn't support real account-sync.
43
+ */
44
+ subscribeAccounts?:
45
+ | ((l: (accounts: string[]) => void) => (() => void) | undefined)
46
+ | undefined
47
+ /** Invoked with the resolved (post-echo-filter) real account list whenever `subscribeAccounts` delivers one. Required whenever `subscribeAccounts` is provided. */
48
+ onAccountsChanged?: (accounts: string[]) => void
37
49
  /** Resolved per-event so a mid-session account/chain change is reflected. */
38
50
  getRouting: () => LifecycleRouting
39
51
  connectionMode: ConnectionMode
@@ -42,8 +54,9 @@ export interface AttachLifecycleOptions {
42
54
  /** Direct page vs production iframe — stamped on `lifecycleTelemetryUnavailable`. */
43
55
  runtime?: 'direct' | 'iframe'
44
56
  /**
45
- * Window (ms) within which a wallet `chainChanged` is treated as the echo of a
46
- * UWC-initiated `switchNetwork` and suppressed. Default 3000.
57
+ * Window (ms) within which a wallet `chainChanged`/`accountsChanged` is
58
+ * treated as the echo of a UWC-initiated `switchNetwork` and suppressed.
59
+ * Default 3000.
47
60
  */
48
61
  echoWindowMs?: number
49
62
  /**
@@ -53,29 +66,37 @@ export interface AttachLifecycleOptions {
53
66
  * provider's echo would otherwise surface as wallet-initiated. Same one-shot
54
67
  * consume semantics as a bus-armed switch.
55
68
  */
56
- initialSwitch?: { chainId: NetworkId; at: number } | undefined
69
+ initialSwitch?:
70
+ | { chainId: NetworkId; address?: string; at: number }
71
+ | undefined
57
72
  now?: () => number
58
73
  }
59
74
 
60
75
  /**
61
76
  * Bridge a connector's PII-safe `WalletLifecycleEvent` stream onto the event bus
62
77
  * as `walletAccountChanged` / `walletChainChanged` / `walletDisconnected` /
63
- * `walletSessionExpired`.
78
+ * `walletSessionExpired`, AND (separately) forward the connector's real
79
+ * account list to `onAccountsChanged` for session-sync.
64
80
  *
65
- * Two correctness concerns are handled here:
81
+ * Three correctness concerns are handled here:
66
82
  * - **Echo suppression.** When UWC drives a `switchNetwork`, the wallet emits its
67
- * own `chainChanged` for the same chain — that's already captured by UWC's
68
- * `networkSwitched` / the switch handoff, so re-emitting it as a wallet-
69
- * initiated `walletChainChanged` would double-count. We watch the bus for
70
- * `networkSwitched` and drop a matching `chainChanged` inside `echoWindowMs`.
71
- * - **Availability is signal.** If the connector can't observe lifecycle, we emit
72
- * `lifecycleTelemetryUnavailable` ONCE so a dashboard can tell "silent" from
73
- * "unwired".
83
+ * own `chainChanged`/`accountsChanged` for the same change — that's already
84
+ * captured by UWC's `networkSwitched` / the switch handoff, so re-emitting it
85
+ * would double-count. We watch the bus for `networkSwitched` and drop a
86
+ * matching echo of either kind inside `echoWindowMs`.
87
+ * - **Availability is signal, per stream.** If the connector can't observe
88
+ * telemetry lifecycle, we emit `lifecycleTelemetryUnavailable` ONCE but
89
+ * that signal is specific to the TELEMETRY stream. A connector missing
90
+ * `onLifecycleEvent` but implementing `onAccountsChanged` (e.g. Tron today)
91
+ * still gets real account-sync; the two streams are independently attached.
92
+ * - **Shared emission budget.** Both streams count against the same
93
+ * `MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT` ceiling — a storming provider
94
+ * doesn't get double budget just because two streams exist.
74
95
  *
75
- * @returns a teardown that removes the connector subscription AND the bus watcher.
96
+ * @returns a teardown that removes both connector subscriptions AND the bus watcher.
76
97
  */
77
98
  export function attachLifecycle(opts: AttachLifecycleOptions): () => void {
78
- const { eventManager, subscribe, getRouting } = opts
99
+ const { eventManager, subscribe, subscribeAccounts, getRouting } = opts
79
100
  const runtime = opts.runtime ?? 'direct'
80
101
  const now = opts.now ?? Date.now
81
102
  const echoWindowMs = opts.echoWindowMs ?? 3000
@@ -89,11 +110,11 @@ export function attachLifecycle(opts: AttachLifecycleOptions): () => void {
89
110
  runtime
90
111
  })
91
112
 
92
- if (!subscribe) {
113
+ if (!subscribe && !subscribeAccounts) {
93
114
  // In the production iframe the injected subscription is deliberately not
94
115
  // attempted (the provider is a Comlink proxy) — the BRIDGE, not the connector,
95
- // is why lifecycle is unobservable. On the direct page, a connector with no
96
- // `onLifecycleEvent` simply has no lifecycle support for this namespace yet.
116
+ // is why lifecycle is unobservable. On the direct page, a connector with
117
+ // neither stream simply has no lifecycle support for this namespace yet.
97
118
  emitUnavailable(
98
119
  runtime === 'iframe'
99
120
  ? 'bridge_subscription_unavailable'
@@ -102,115 +123,206 @@ export function attachLifecycle(opts: AttachLifecycleOptions): () => void {
102
123
  return () => {}
103
124
  }
104
125
 
105
- // Record UWC-initiated switches so the wallet's chainChanged echo can be
106
- // dropped. Armed from BOTH signals: `awaitingWallet` (operation switchNetwork)
107
- // arms at the handoff START, because some providers emit their chainChanged
108
- // BEFORE the switch request resolves i.e. before `networkSwitched` exists
109
- // and `networkSwitched` re-arms at completion for the late-echo case. The
110
- // re-arm means one extra suppression window can follow an early echo; the
111
- // only event it could swallow is a sub-window user re-switch to the chain
112
- // that just became active — accepted over double-counting every early echo.
113
- let lastSwitch: { chainId: NetworkId; at: number } | undefined =
114
- opts.initialSwitch
126
+ // Record UWC-initiated switches so a wallet echo can be dropped. Armed from
127
+ // BOTH signals: `awaitingWallet` (operation switchNetwork) arms at the
128
+ // handoff START, because some providers emit their chainChanged BEFORE the
129
+ // switch request resolves, and `networkSwitched` re-arms at completion
130
+ // (also carrying the resolved address, for the accounts echo check — the
131
+ // target address isn't known before the switch resolves, so there is no
132
+ // early-arm equivalent for the address half).
133
+ //
134
+ // A LIST, not a single slot: two rapid, back-to-back switches (A then B)
135
+ // each get their own pending entry. A single slot would have the SECOND
136
+ // switch's arm silently erase the FIRST switch's still-unconsumed window,
137
+ // so a delayed wallet echo for A (arriving after B has already completed)
138
+ // would be misclassified as a genuine wallet-initiated change instead of
139
+ // the suppressed echo it actually is.
140
+ type PendingSwitch = {
141
+ chainId?: NetworkId | undefined
142
+ address?: string | undefined
143
+ at: number
144
+ }
145
+ let pendingSwitches: PendingSwitch[] = opts.initialSwitch
146
+ ? [opts.initialSwitch]
147
+ : []
148
+ const pruneExpired = () => {
149
+ pendingSwitches = pendingSwitches.filter(p => now() - p.at <= echoWindowMs)
150
+ }
151
+ // `awaitingWallet` then `networkSwitched` are usually the START and
152
+ // COMPLETION of the SAME switch — re-arming updates that existing entry
153
+ // (refreshing `at`, adding the resolved address) rather than pushing a
154
+ // duplicate, which would otherwise leave a phantom entry behind after only
155
+ // one of the two is consumed by a single wallet echo.
156
+ const armOrRearm = (partial: {
157
+ chainId?: NetworkId | undefined
158
+ address?: string | undefined
159
+ }) => {
160
+ pruneExpired()
161
+ if (partial.chainId !== undefined) {
162
+ const idx = pendingSwitches.findIndex(p => p.chainId === partial.chainId)
163
+ if (idx !== -1) {
164
+ pendingSwitches[idx] = {
165
+ ...pendingSwitches[idx]!,
166
+ ...partial,
167
+ at: now()
168
+ }
169
+ return
170
+ }
171
+ }
172
+ pendingSwitches.push({ ...partial, at: now() })
173
+ }
115
174
  const offAwaiting = eventManager.on('awaitingWallet', data => {
116
175
  if (data.operation === 'switchNetwork' && data.chainId !== undefined) {
117
- lastSwitch = { chainId: data.chainId, at: now() }
176
+ armOrRearm({ chainId: data.chainId })
118
177
  }
119
178
  })
120
179
  const offSwitched = eventManager.on('networkSwitched', data => {
121
- lastSwitch = { chainId: data.network.id, at: now() }
180
+ armOrRearm({ chainId: data.network.id, address: data.address })
122
181
  })
123
182
  const detachBusWatchers = () => {
124
183
  offAwaiting()
125
184
  offSwitched()
126
185
  }
127
186
 
187
+ // Emission budget for THIS attachment, shared across both streams —
188
+ // suppressed echoes don't spend it.
189
+ let emitted = 0
190
+ let capNotified = false
191
+ const overCap = (): boolean => {
192
+ if (emitted >= MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT) {
193
+ // Mark the trip ONCE — a silent cap would be indistinguishable from a
194
+ // provider that simply went quiet.
195
+ if (!capNotified) {
196
+ capNotified = true
197
+ emitUnavailable('emission_cap')
198
+ }
199
+ return true
200
+ }
201
+ return false
202
+ }
203
+
128
204
  // Defense-in-depth: a connector whose subscribe THROWS (e.g. a provider whose
129
205
  // `.on` rejects an un-proxied handler across the iframe bridge) must degrade to
130
206
  // "unavailable", never break the connect path that called us.
131
207
  let unsub: (() => void) | undefined
132
- // Emission budget for THIS attachment — suppressed echoes don't spend it.
133
- let emitted = 0
134
- let capNotified = false
135
- try {
136
- unsub = subscribe(e => {
137
- // The whole body is guarded: wallet SDKs dispatch their listeners
138
- // unguarded (e.g. TonConnect's bare `forEach`), so a throw here — from
139
- // `getRouting()` or any future edit — would abort delivery to the
140
- // wallet's OWN later subscribers, including one resolving a pending
141
- // connect. Telemetry must never do that.
142
- try {
143
- if (emitted >= MAX_LIFECYCLE_EVENTS_PER_ATTACHMENT) {
144
- // Mark the trip ONCE — a silent cap would be indistinguishable from a
145
- // provider that simply went quiet.
146
- if (!capNotified) {
147
- capNotified = true
148
- emitUnavailable('emission_cap')
208
+ if (subscribe) {
209
+ try {
210
+ unsub = subscribe(e => {
211
+ // The whole body is guarded: wallet SDKs dispatch their listeners
212
+ // unguarded (e.g. TonConnect's bare `forEach`), so a throw here would
213
+ // abort delivery to the wallet's OWN later subscribers, including one
214
+ // resolving a pending connect. Telemetry must never do that.
215
+ try {
216
+ if (overCap()) return
217
+ const routing = getRouting()
218
+ switch (e.type) {
219
+ case 'accountChanged':
220
+ emitted++
221
+ eventManager.emit('walletAccountChanged', routing)
222
+ return
223
+ case 'chainChanged': {
224
+ pruneExpired()
225
+ const idx =
226
+ e.chainId !== undefined
227
+ ? pendingSwitches.findIndex(
228
+ p => p.chainId !== undefined && p.chainId === e.chainId
229
+ )
230
+ : -1
231
+ if (idx !== -1) {
232
+ // Consume the echo: a UWC switch produces exactly one wallet echo, so
233
+ // a LATER chainChanged to the same chain inside the window is a genuine
234
+ // user-initiated switch-back and must not be suppressed. Only the
235
+ // chain half is consumed — a still-pending address echo (if the
236
+ // switch also changed address format) must still be checked; drop the
237
+ // entry entirely once nothing is left pending on it.
238
+ const entry = pendingSwitches[idx]!
239
+ if (entry.address !== undefined) {
240
+ pendingSwitches[idx] = { ...entry, chainId: undefined }
241
+ } else {
242
+ pendingSwitches.splice(idx, 1)
243
+ }
244
+ return
245
+ }
246
+ emitted++
247
+ eventManager.emit('walletChainChanged', {
248
+ ...routing,
249
+ ...(e.chainId !== undefined ? { chainId: e.chainId } : {})
250
+ })
251
+ return
252
+ }
253
+ case 'disconnect':
254
+ emitted++
255
+ eventManager.emit('walletDisconnected', routing)
256
+ return
257
+ case 'sessionExpired':
258
+ emitted++
259
+ eventManager.emit('walletSessionExpired', routing)
260
+ return
149
261
  }
150
- return
262
+ } catch {
263
+ // Swallowed: better to drop one lifecycle record than to break the
264
+ // wallet's event dispatch.
151
265
  }
152
- const routing = getRouting()
153
- switch (e.type) {
154
- case 'accountChanged':
155
- emitted++
156
- eventManager.emit('walletAccountChanged', routing)
157
- return
158
- case 'chainChanged': {
159
- const isEcho =
160
- !!lastSwitch &&
161
- e.chainId !== undefined &&
162
- lastSwitch.chainId === e.chainId &&
163
- now() - lastSwitch.at <= echoWindowMs
164
- if (isEcho) {
165
- // Consume the echo: a UWC switch produces exactly one wallet echo, so
166
- // a LATER chainChanged to the same chain inside the window is a genuine
167
- // user-initiated switch-back and must not be suppressed.
168
- lastSwitch = undefined
169
- return
266
+ })
267
+ } catch {
268
+ unsub = undefined
269
+ }
270
+ if (!unsub) {
271
+ // The connector accepted the subscribe call but this session has no
272
+ // observable telemetry surface (returned undefined or threw). Over the
273
+ // bridge, that absence is the bridge's doing, not the provider's.
274
+ emitUnavailable(
275
+ runtime === 'iframe'
276
+ ? 'bridge_subscription_unavailable'
277
+ : 'provider_subscription_unavailable'
278
+ )
279
+ }
280
+ }
281
+
282
+ let unsubAccounts: (() => void) | undefined
283
+ if (subscribeAccounts) {
284
+ try {
285
+ unsubAccounts = subscribeAccounts(accounts => {
286
+ try {
287
+ if (overCap()) return
288
+ const primary = accounts[0]
289
+ pruneExpired()
290
+ const idx =
291
+ accounts.length > 0
292
+ ? pendingSwitches.findIndex(
293
+ p => p.address !== undefined && p.address === primary
294
+ )
295
+ : -1
296
+ if (idx !== -1) {
297
+ // Same one-shot consume as the chainChanged echo above, mirrored
298
+ // for the address half.
299
+ const entry = pendingSwitches[idx]!
300
+ if (entry.chainId !== undefined) {
301
+ pendingSwitches[idx] = { ...entry, address: undefined }
302
+ } else {
303
+ pendingSwitches.splice(idx, 1)
170
304
  }
171
- emitted++
172
- eventManager.emit('walletChainChanged', {
173
- ...routing,
174
- ...(e.chainId !== undefined ? { chainId: e.chainId } : {})
175
- })
176
305
  return
177
306
  }
178
- case 'disconnect':
179
- emitted++
180
- eventManager.emit('walletDisconnected', routing)
181
- return
182
- case 'sessionExpired':
183
- emitted++
184
- eventManager.emit('walletSessionExpired', routing)
185
- return
307
+ emitted++
308
+ opts.onAccountsChanged?.(accounts)
309
+ } catch {
310
+ // Swallowed: never break the wallet SDK's own listener dispatch.
186
311
  }
187
- } catch {
188
- // Swallowed: better to drop one lifecycle record than to break the
189
- // wallet's event dispatch.
190
- }
191
- })
192
- } catch {
193
- unsub = undefined
312
+ })
313
+ } catch {
314
+ unsubAccounts = undefined
315
+ }
194
316
  }
195
317
 
196
- // The connector accepted the subscribe call but this session has no observable
197
- // surface (returned undefined) or threw — treat as "unavailable" and don't
198
- // leave the bus watchers attached.
199
- if (!unsub) {
318
+ if (!unsub && !unsubAccounts) {
200
319
  detachBusWatchers()
201
- // `onLifecycleEvent` ran but exposed no live subscription surface for this
202
- // session (returned undefined or threw). Over the bridge, that absence is the
203
- // bridge's doing, not the provider's.
204
- emitUnavailable(
205
- runtime === 'iframe'
206
- ? 'bridge_subscription_unavailable'
207
- : 'provider_subscription_unavailable'
208
- )
209
320
  return () => {}
210
321
  }
211
322
 
212
323
  return () => {
213
324
  detachBusWatchers()
214
- unsub()
325
+ unsub?.()
326
+ unsubAccounts?.()
215
327
  }
216
328
  }
@@ -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
  })