@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.
@@ -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
  constructor(
31
37
  private networks: Network[],
@@ -154,6 +160,11 @@ export class ConnectionService {
154
160
  // disconnect, expiry) from the live provider — telemetry only, never changes
155
161
  // connection behaviour. Torn down on disconnect.
156
162
  this.attachLifecycle(connectionMode, wallet.id, network.namespace)
163
+ this.attachSecondaryAccountSync(
164
+ connectionMode,
165
+ wallet.id,
166
+ network.namespace
167
+ )
157
168
  }
158
169
 
159
170
  /** Wire the active connector's lifecycle stream onto the bus (or signal absence). */
@@ -161,7 +172,7 @@ export class ConnectionService {
161
172
  connectionMode: ConnectionMode,
162
173
  walletId: string,
163
174
  namespace: Namespace,
164
- initialSwitch?: { chainId: NetworkId; at: number }
175
+ initialSwitch?: { chainId: NetworkId; address?: string; at: number }
165
176
  ): void {
166
177
  this.teardownLifecycle()
167
178
  const connector = this.activeConnector
@@ -181,6 +192,16 @@ export class ConnectionService {
181
192
  walletId
182
193
  })
183
194
  : undefined,
195
+ subscribeAccounts: connector?.onAccountsChanged
196
+ ? listener =>
197
+ connector.onAccountsChanged!(listener, {
198
+ connectionMode,
199
+ namespace,
200
+ walletId
201
+ })
202
+ : undefined,
203
+ onAccountsChanged: accounts =>
204
+ this.handleAccountsChanged(accounts, namespace),
184
205
  getRouting: () => {
185
206
  const s = this.sessionManager.getSession()
186
207
  return {
@@ -203,6 +224,63 @@ export class ConnectionService {
203
224
  })
204
225
  }
205
226
 
227
+ /**
228
+ * Apply a REAL account-list change (from `Connector.onAccountsChanged`, via
229
+ * the lifecycle bridge's second stream) to the session. `namespace` is the
230
+ * namespace of the provider that reported the change — `activeAddress`
231
+ * only moves when that matches the session's currently active network; an
232
+ * empty list is a full, wallet-initiated disconnect.
233
+ */
234
+ private handleAccountsChanged(
235
+ accounts: string[],
236
+ namespace: Namespace
237
+ ): void {
238
+ if (accounts.length === 0) {
239
+ // Idempotency guard: this runs synchronously FROM INSIDE the
240
+ // connector's own accountsChanged dispatch — finishDisconnect() below
241
+ // tears down (among other things) the very subscription that is
242
+ // currently delivering this callback. If a wallet double-fires the
243
+ // empty-array disconnect signal (or one slips through before teardown
244
+ // takes effect), `activeConnector` is already null from the first call
245
+ // — skip re-clearing an already-cleared session and re-emitting
246
+ // disconnected/sessionChanged a second time. Scoped to THIS wallet-
247
+ // initiated path only; the public disconnect() below still always
248
+ // runs finishDisconnect() defensively, even with no active connector.
249
+ if (this.activeConnector === null) return
250
+ // The wallet ended the session on its own side, but the connector's
251
+ // OWN local state (e.g. EthereumWalletService's cached account/
252
+ // provider/uuid) is never cleared by finishDisconnect() below — it
253
+ // only clears core's sessionManager. Left uncleared, a later connect
254
+ // to a DIFFERENT wallet reads that stale cached account via
255
+ // checkExistingConnections() and never rebinds to the new wallet's
256
+ // provider/uuid, silently keeping the old wallet's address live under
257
+ // the new wallet's session. disconnect() is fire-and-forget and
258
+ // error-swallowed here: for injected connectors it's local-only
259
+ // cleanup (safe to call any number of times), and for protocol-based
260
+ // connectors that might reject on an already-closed session, there is
261
+ // no caller to propagate that error to anyway.
262
+ this.activeConnector?.disconnect?.().catch(() => {})
263
+ this.finishDisconnect()
264
+ return
265
+ }
266
+ const newAddress = accounts[0]!
267
+ const session = this.sessionManager.getSession()
268
+ const availableAddresses = session.availableAddresses.map(addr => {
269
+ const network = this.networks.find(n => n.id === addr.networkId)
270
+ return network?.namespace === namespace
271
+ ? { ...addr, address: newAddress }
272
+ : addr
273
+ })
274
+ const activeAddress =
275
+ session.activeNetwork?.namespace === namespace
276
+ ? newAddress
277
+ : session.activeAddress
278
+ this.sessionManager.updateSession({ activeAddress, availableAddresses })
279
+ this.eventManager.emit('sessionChanged', {
280
+ session: this.sessionManager.getSession()
281
+ })
282
+ }
283
+
206
284
  /**
207
285
  * Remove the active provider-lifecycle subscription (EIP-1193 `.on` / wallet-
208
286
  * adapter / SignClient listeners) WITHOUT disconnecting the wallet. Idempotent.
@@ -213,6 +291,8 @@ export class ConnectionService {
213
291
  teardownLifecycle(): void {
214
292
  this.lifecycleTeardown?.()
215
293
  this.lifecycleTeardown = undefined
294
+ for (const teardown of this.secondaryAccountSyncTeardowns) teardown()
295
+ this.secondaryAccountSyncTeardowns = []
216
296
  }
217
297
 
218
298
  /**
@@ -237,13 +317,95 @@ export class ConnectionService {
237
317
  session.connectionMode,
238
318
  session.activeWallet.id,
239
319
  session.activeNetwork.namespace,
240
- // The reattach follows a JUST-completed switch to the now-active chain
241
- // arm echo suppression for it so the new provider's echo isn't mistaken
242
- // for a wallet-initiated change (one-shot, same as bus-armed switches).
243
- { chainId: session.activeNetwork.id, at: Date.now() }
320
+ // The reattach follows a JUST-completed switch to the now-active chain
321
+ // (and address) — arm echo suppression for both so the new provider's
322
+ // echo isn't mistaken for a wallet-initiated change (one-shot, same as
323
+ // bus-armed switches).
324
+ {
325
+ chainId: session.activeNetwork.id,
326
+ ...(session.activeAddress && { address: session.activeAddress }),
327
+ at: Date.now()
328
+ }
329
+ )
330
+ // Re-derive the secondary set against the NOW-active namespace — a
331
+ // cross-namespace switch swaps which namespace is "secondary" (the
332
+ // attachLifecycle above already tore the previous set down via
333
+ // teardownLifecycle).
334
+ this.attachSecondaryAccountSync(
335
+ session.connectionMode,
336
+ session.activeWallet.id,
337
+ session.activeNetwork.namespace
244
338
  )
245
339
  }
246
340
 
341
+ /**
342
+ * Attach account-sync for every OTHER namespace the session's
343
+ * `availableAddresses` span. `attachLifecycle` observes only the ACTIVE
344
+ * network's namespace, but the cross-namespace gather seeds addresses from
345
+ * other namespaces' live surfaces at connect time — without their own
346
+ * subscription, a wallet-side account switch on such a namespace is never
347
+ * observed and the session serves the connect-time addresses forever (e.g.
348
+ * a Solana-active MetaMask session whose eip155 addresses never follow the
349
+ * extension's account switch).
350
+ *
351
+ * Accounts-sync ONLY, no lifecycle-telemetry stream: the active namespace
352
+ * stays the single source of wallet* telemetry and of the wallet-initiated
353
+ * disconnect signal.
354
+ */
355
+ private attachSecondaryAccountSync(
356
+ connectionMode: ConnectionMode,
357
+ walletId: string,
358
+ activeNamespace: Namespace
359
+ ): void {
360
+ const connector = this.activeConnector
361
+ if (!connector?.onAccountsChanged) return
362
+
363
+ const session = this.sessionManager.getSession()
364
+ const secondaryNamespaces = new Set<Namespace>()
365
+ for (const addr of session.availableAddresses) {
366
+ const namespace = this.networks.find(
367
+ n => n.id === addr.networkId
368
+ )?.namespace
369
+ if (namespace && namespace !== activeNamespace) {
370
+ secondaryNamespaces.add(namespace)
371
+ }
372
+ }
373
+
374
+ const runtime =
375
+ (globalThis as { UWCBridgeChildInitialized?: boolean })
376
+ .UWCBridgeChildInitialized === true
377
+ ? ('iframe' as const)
378
+ : ('direct' as const)
379
+
380
+ for (const namespace of secondaryNamespaces) {
381
+ this.secondaryAccountSyncTeardowns.push(
382
+ attachLifecycle({
383
+ eventManager: this.eventManager,
384
+ subscribe: undefined,
385
+ subscribeAccounts: listener =>
386
+ connector.onAccountsChanged!(listener, {
387
+ connectionMode,
388
+ namespace,
389
+ walletId
390
+ }),
391
+ onAccountsChanged: accounts => {
392
+ // An empty list from a SECONDARY namespace means that namespace's
393
+ // own authorization ended — NOT the session. Only the active
394
+ // namespace's stream may signal the full wallet-initiated
395
+ // disconnect (handleAccountsChanged's empty-list path).
396
+ if (accounts.length === 0) return
397
+ this.handleAccountsChanged(accounts, namespace)
398
+ },
399
+ getRouting: () => ({ connectionMode, walletId, namespace }),
400
+ connectionMode,
401
+ walletId,
402
+ namespace,
403
+ runtime
404
+ })
405
+ )
406
+ }
407
+ }
408
+
247
409
  private async connectWithURIPoll(
248
410
  connector: Connector,
249
411
  network: Network,
@@ -420,14 +582,32 @@ export class ConnectionService {
420
582
  await this.activeConnector.disconnect()
421
583
  }
422
584
  } finally {
423
- this.activeConnector = null
424
- this.sessionManager.clearSession()
425
- const session = this.sessionManager.getSession()
426
- this.eventManager.emit('disconnected', undefined)
427
- this.eventManager.emit('sessionChanged', { session })
585
+ this.finishDisconnect()
428
586
  }
429
587
  }
430
588
 
589
+ /**
590
+ * Clears core's own local state (session, active connector reference,
591
+ * lifecycle subscription) and emits the disconnect events. Does NOT itself
592
+ * call the connector's `disconnect()` — that happens in each CALLER,
593
+ * because the two callers need different semantics: the public
594
+ * `disconnect()` above awaits it (the caller may care whether the wallet
595
+ * side actually tore down); the wallet-initiated path in
596
+ * `handleAccountsChanged` fires it and swallows the result (the wallet
597
+ * already ended the session on its own side, but the connector's own
598
+ * LOCAL state — e.g. a cached account/uuid — still needs clearing, or a
599
+ * later connect to a different wallet reads it as stale and never
600
+ * rebinds).
601
+ */
602
+ private finishDisconnect(): void {
603
+ this.teardownLifecycle()
604
+ this.activeConnector = null
605
+ this.sessionManager.clearSession()
606
+ const session = this.sessionManager.getSession()
607
+ this.eventManager.emit('disconnected', undefined)
608
+ this.eventManager.emit('sessionChanged', { session })
609
+ }
610
+
431
611
  getConnectionURI(): string | undefined {
432
612
  if (
433
613
  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
  })
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated from package.json by scripts/generate-version.mjs — do not edit.
2
- export const UWC_CORE_VERSION = '1.1.0'
2
+ export const UWC_CORE_VERSION = '1.1.1'