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

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