@meshconnect/uwc-core 1.10.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/events.d.ts +5 -1
  2. package/dist/events.d.ts.map +1 -1
  3. package/dist/events.js.map +1 -1
  4. package/dist/observability/lifecycle-bridge.d.ts +31 -13
  5. package/dist/observability/lifecycle-bridge.d.ts.map +1 -1
  6. package/dist/observability/lifecycle-bridge.js +194 -92
  7. package/dist/observability/lifecycle-bridge.js.map +1 -1
  8. package/dist/services/connection-service.d.ts +86 -0
  9. package/dist/services/connection-service.d.ts.map +1 -1
  10. package/dist/services/connection-service.js +354 -9
  11. package/dist/services/connection-service.js.map +1 -1
  12. package/dist/services/network-switch-service.d.ts.map +1 -1
  13. package/dist/services/network-switch-service.js +4 -1
  14. package/dist/services/network-switch-service.js.map +1 -1
  15. package/dist/services/signature-service.d.ts +4 -3
  16. package/dist/services/signature-service.d.ts.map +1 -1
  17. package/dist/services/signature-service.js +2 -1
  18. package/dist/services/signature-service.js.map +1 -1
  19. package/dist/universal-wallet-connector.d.ts +5 -3
  20. package/dist/universal-wallet-connector.d.ts.map +1 -1
  21. package/dist/universal-wallet-connector.js +3 -1
  22. package/dist/universal-wallet-connector.js.map +1 -1
  23. package/dist/version.d.ts +1 -1
  24. package/dist/version.d.ts.map +1 -1
  25. package/dist/version.js +1 -1
  26. package/dist/version.js.map +1 -1
  27. package/package.json +5 -5
  28. package/src/events.ts +5 -2
  29. package/src/observability/lifecycle-bridge.test.ts +316 -0
  30. package/src/observability/lifecycle-bridge.ts +240 -99
  31. package/src/services/connection-service.test.ts +798 -1
  32. package/src/services/connection-service.ts +410 -10
  33. package/src/services/network-switch-service.test.ts +33 -0
  34. package/src/services/network-switch-service.ts +4 -1
  35. package/src/services/signature-service.test.ts +9 -2
  36. package/src/services/signature-service.ts +4 -3
  37. package/src/universal-wallet-connector.ts +5 -3
  38. package/src/version.ts +1 -1
@@ -22,10 +22,23 @@ export interface ServiceOptions {
22
22
  signal?: AbortSignal
23
23
  }
24
24
 
25
+ /** Sibling-namespace live-account re-read poll: absorbs the lag while a wallet
26
+ * re-derives a sibling namespace's address (e.g. MetaMask's Solana Snap) after
27
+ * an account switch on another namespace. Bounded so a genuinely-unchanged
28
+ * sibling gives up quickly. */
29
+ export const SIBLING_REFRESH_MAX_ATTEMPTS = 5
30
+ export const SIBLING_REFRESH_DELAY_MS = 200
31
+
25
32
  export class ConnectionService {
26
33
  private activeConnector: Connector | null = null
27
34
  /** Tears down the post-connect lifecycle subscription; set while connected. */
28
35
  private lifecycleTeardown: (() => void) | undefined
36
+ /**
37
+ * Teardowns for the accounts-sync attachments of the session's SECONDARY
38
+ * namespaces (see `attachSecondaryAccountSync`); set while connected,
39
+ * cleared together with `lifecycleTeardown`.
40
+ */
41
+ private secondaryAccountSyncTeardowns: Array<() => void> = []
29
42
  /**
30
43
  * Abort handle for the CURRENT in-flight connect attempt. A re-entrant
31
44
  * connect() aborts this before starting, so a superseded attempt's URI poll
@@ -34,6 +47,13 @@ export class ConnectionService {
34
47
  * each reading the shared activeConnector, and flood telemetry).
35
48
  */
36
49
  private inFlightAbort: AbortController | null = null
50
+ /**
51
+ * Monotonic token for the in-flight cross-namespace sibling refresh (see
52
+ * `refreshSiblingNamespaces`). A newer account switch, a teardown, or a
53
+ * disconnect bumps it so a still-polling older refresh aborts before it
54
+ * writes a now-stale sibling address into the session.
55
+ */
56
+ private siblingRefreshGeneration = 0
37
57
 
38
58
  constructor(
39
59
  private networks: Network[],
@@ -200,6 +220,11 @@ export class ConnectionService {
200
220
  // disconnect, expiry) from the live provider — telemetry only, never changes
201
221
  // connection behaviour. Torn down on disconnect.
202
222
  this.attachLifecycle(connectionMode, wallet.id, network.namespace)
223
+ this.attachSecondaryAccountSync(
224
+ connectionMode,
225
+ wallet.id,
226
+ network.namespace
227
+ )
203
228
  }
204
229
 
205
230
  /** Wire the active connector's lifecycle stream onto the bus (or signal absence). */
@@ -207,7 +232,7 @@ export class ConnectionService {
207
232
  connectionMode: ConnectionMode,
208
233
  walletId: string,
209
234
  namespace: Namespace,
210
- initialSwitch?: { chainId: NetworkId; at: number }
235
+ initialSwitch?: { chainId: NetworkId; address?: string; at: number }
211
236
  ): void {
212
237
  this.teardownLifecycle()
213
238
  const connector = this.activeConnector
@@ -227,6 +252,16 @@ export class ConnectionService {
227
252
  walletId
228
253
  })
229
254
  : undefined,
255
+ subscribeAccounts: connector?.onAccountsChanged
256
+ ? listener =>
257
+ connector.onAccountsChanged!(listener, {
258
+ connectionMode,
259
+ namespace,
260
+ walletId
261
+ })
262
+ : undefined,
263
+ onAccountsChanged: accounts =>
264
+ this.handleAccountsChanged(accounts, namespace),
230
265
  getRouting: () => {
231
266
  const s = this.sessionManager.getSession()
232
267
  return {
@@ -249,6 +284,71 @@ export class ConnectionService {
249
284
  })
250
285
  }
251
286
 
287
+ /**
288
+ * Apply a REAL account-list change (from `Connector.onAccountsChanged`, via
289
+ * the lifecycle bridge's second stream) to the session. `namespace` is the
290
+ * namespace of the provider that reported the change — `activeAddress`
291
+ * only moves when that matches the session's currently active network; an
292
+ * empty list is a full, wallet-initiated disconnect.
293
+ */
294
+ private handleAccountsChanged(
295
+ accounts: string[],
296
+ namespace: Namespace
297
+ ): void {
298
+ if (accounts.length === 0) {
299
+ // Idempotency guard: this runs synchronously FROM INSIDE the
300
+ // connector's own accountsChanged dispatch — finishDisconnect() below
301
+ // tears down (among other things) the very subscription that is
302
+ // currently delivering this callback. If a wallet double-fires the
303
+ // empty-array disconnect signal (or one slips through before teardown
304
+ // takes effect), `activeConnector` is already null from the first call
305
+ // — skip re-clearing an already-cleared session and re-emitting
306
+ // disconnected/sessionChanged a second time. Scoped to THIS wallet-
307
+ // initiated path only; the public disconnect() below still always
308
+ // runs finishDisconnect() defensively, even with no active connector.
309
+ if (this.activeConnector === null) return
310
+ // The wallet ended the session on its own side, but the connector's
311
+ // OWN local state (e.g. EthereumWalletService's cached account/
312
+ // provider/uuid) is never cleared by finishDisconnect() below — it
313
+ // only clears core's sessionManager. Left uncleared, a later connect
314
+ // to a DIFFERENT wallet reads that stale cached account via
315
+ // checkExistingConnections() and never rebinds to the new wallet's
316
+ // provider/uuid, silently keeping the old wallet's address live under
317
+ // the new wallet's session. disconnect() is fire-and-forget and
318
+ // error-swallowed here: for injected connectors it's local-only
319
+ // cleanup (safe to call any number of times), and for protocol-based
320
+ // connectors that might reject on an already-closed session, there is
321
+ // no caller to propagate that error to anyway.
322
+ this.activeConnector?.disconnect?.().catch(() => {})
323
+ this.finishDisconnect()
324
+ return
325
+ }
326
+ const newAddress = accounts[0]!
327
+ const session = this.sessionManager.getSession()
328
+ const availableAddresses = session.availableAddresses.map(addr => {
329
+ const network = this.networks.find(n => n.id === addr.networkId)
330
+ return network?.namespace === namespace
331
+ ? { ...addr, address: newAddress }
332
+ : addr
333
+ })
334
+ const activeAddress =
335
+ session.activeNetwork?.namespace === namespace
336
+ ? newAddress
337
+ : session.activeAddress
338
+ this.sessionManager.updateSession({ activeAddress, availableAddresses })
339
+ this.eventManager.emit('sessionChanged', {
340
+ session: this.sessionManager.getSession()
341
+ })
342
+
343
+ // A single wallet account drives ALL its namespaces (e.g. MetaMask's Solana
344
+ // Snap derives its key from the selected EVM account). The wallet only fired
345
+ // an accountsChanged for THIS namespace; refresh the live account of every
346
+ // other connected namespace so the session doesn't serve a stale sibling
347
+ // address. Fire-and-forget: we're inside the wallet's synchronous event
348
+ // dispatch and must not await.
349
+ void this.refreshSiblingNamespaces(namespace)
350
+ }
351
+
252
352
  /**
253
353
  * Remove the active provider-lifecycle subscription (EIP-1193 `.on` / wallet-
254
354
  * adapter / SignClient listeners) WITHOUT disconnecting the wallet. Idempotent.
@@ -259,6 +359,13 @@ export class ConnectionService {
259
359
  teardownLifecycle(): void {
260
360
  this.lifecycleTeardown?.()
261
361
  this.lifecycleTeardown = undefined
362
+ for (const teardown of this.secondaryAccountSyncTeardowns) teardown()
363
+ this.secondaryAccountSyncTeardowns = []
364
+ // Supersede any in-flight sibling-namespace refresh (see
365
+ // `refreshSiblingNamespaces`) — a still-polling refresh must not write a
366
+ // now-stale address after teardown/disconnect. `finishDisconnect()` calls
367
+ // this too, so one bump here covers both paths.
368
+ this.siblingRefreshGeneration++
262
369
  }
263
370
 
264
371
  /**
@@ -283,11 +390,286 @@ export class ConnectionService {
283
390
  session.connectionMode,
284
391
  session.activeWallet.id,
285
392
  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() }
393
+ // The reattach follows a JUST-completed switch to the now-active chain
394
+ // (and address) — arm echo suppression for both so the new provider's
395
+ // echo isn't mistaken for a wallet-initiated change (one-shot, same as
396
+ // bus-armed switches).
397
+ {
398
+ chainId: session.activeNetwork.id,
399
+ ...(session.activeAddress && { address: session.activeAddress }),
400
+ at: Date.now()
401
+ }
402
+ )
403
+ // Re-derive the secondary set against the NOW-active namespace — a
404
+ // cross-namespace switch swaps which namespace is "secondary" (the
405
+ // attachLifecycle above already tore the previous set down via
406
+ // teardownLifecycle).
407
+ this.attachSecondaryAccountSync(
408
+ session.connectionMode,
409
+ session.activeWallet.id,
410
+ session.activeNetwork.namespace
411
+ )
412
+ }
413
+
414
+ /**
415
+ * Attach account-sync for every OTHER namespace the session's
416
+ * `availableAddresses` span. `attachLifecycle` observes only the ACTIVE
417
+ * network's namespace, but the cross-namespace gather seeds addresses from
418
+ * other namespaces' live surfaces at connect time — without their own
419
+ * subscription, a wallet-side account switch on such a namespace is never
420
+ * observed and the session serves the connect-time addresses forever (e.g.
421
+ * a Solana-active MetaMask session whose eip155 addresses never follow the
422
+ * extension's account switch).
423
+ *
424
+ * Accounts-sync ONLY, no lifecycle-telemetry stream: the active namespace
425
+ * stays the single source of wallet* telemetry and of the wallet-initiated
426
+ * disconnect signal.
427
+ */
428
+ private attachSecondaryAccountSync(
429
+ connectionMode: ConnectionMode,
430
+ walletId: string,
431
+ activeNamespace: Namespace
432
+ ): void {
433
+ const connector = this.activeConnector
434
+ if (!connector?.onAccountsChanged) return
435
+
436
+ const session = this.sessionManager.getSession()
437
+ const secondaryNamespaces = new Set<Namespace>()
438
+ for (const addr of session.availableAddresses) {
439
+ const namespace = this.networks.find(
440
+ n => n.id === addr.networkId
441
+ )?.namespace
442
+ if (namespace && namespace !== activeNamespace) {
443
+ secondaryNamespaces.add(namespace)
444
+ }
445
+ }
446
+
447
+ const runtime =
448
+ (globalThis as { UWCBridgeChildInitialized?: boolean })
449
+ .UWCBridgeChildInitialized === true
450
+ ? ('iframe' as const)
451
+ : ('direct' as const)
452
+
453
+ for (const namespace of secondaryNamespaces) {
454
+ this.secondaryAccountSyncTeardowns.push(
455
+ attachLifecycle({
456
+ eventManager: this.eventManager,
457
+ subscribe: undefined,
458
+ subscribeAccounts: listener =>
459
+ connector.onAccountsChanged!(listener, {
460
+ connectionMode,
461
+ namespace,
462
+ walletId
463
+ }),
464
+ onAccountsChanged: accounts => {
465
+ // An empty list from a SECONDARY namespace is USUALLY just that
466
+ // namespace's own de-auth, not the whole session — so we don't
467
+ // clobber the session on a partial change. BUT some wallets emit a
468
+ // disconnect ONLY on a secondary namespace: e.g. MetaMask (EVM +
469
+ // Solana Snap) fires `accountsChanged([])` on its eip155 leg but its
470
+ // Snap-derived active (solana) namespace emits nothing on a full
471
+ // wallet disconnect. Relying on the active namespace's stream alone
472
+ // then never sees the disconnect. So on a secondary empty, verify
473
+ // whether the wallet is FULLY gone (active namespace has no live
474
+ // account either) and, if so, clear the session.
475
+ if (accounts.length === 0) {
476
+ void this.clearSessionIfWalletFullyDisconnected(
477
+ activeNamespace,
478
+ connectionMode,
479
+ walletId
480
+ )
481
+ return
482
+ }
483
+ // Surface the secondary-namespace switch for observability. The
484
+ // ACTIVE namespace's switch already emits `walletAccountChanged` via
485
+ // the lifecycle bridge; secondary namespaces have accounts-sync only
486
+ // (no lifecycle stream), so emit it here for parity → `uwcObserver`
487
+ // forwards it to RUM as `uwc.walletAccountChanged`. Secondary-only,
488
+ // so it never double-counts the active namespace's emit.
489
+ this.eventManager.emit('walletAccountChanged', {
490
+ connectionMode,
491
+ walletId,
492
+ namespace
493
+ })
494
+ this.handleAccountsChanged(accounts, namespace)
495
+ },
496
+ getRouting: () => ({ connectionMode, walletId, namespace }),
497
+ connectionMode,
498
+ walletId,
499
+ namespace,
500
+ runtime
501
+ })
502
+ )
503
+ }
504
+ }
505
+
506
+ /**
507
+ * A SECONDARY namespace just reported an empty account list. Determine whether
508
+ * this is a full wallet-initiated disconnect (vs. only that namespace losing
509
+ * authorization) by reading the ACTIVE namespace's LIVE account: if it's also
510
+ * gone, the whole wallet disconnected — clear the session via the active
511
+ * namespace's empty-list path. If the active namespace still has an account
512
+ * (a genuine partial change) or we can't tell, keep the conservative no-op.
513
+ *
514
+ * Motivating case: MetaMask's Solana-Snap active namespace emits no disconnect
515
+ * signal, while its eip155 (secondary) leg does — so the secondary empty is the
516
+ * only signal the wallet is gone.
517
+ */
518
+ private async clearSessionIfWalletFullyDisconnected(
519
+ activeNamespace: Namespace,
520
+ connectionMode: ConnectionMode,
521
+ walletId: string
522
+ ): Promise<void> {
523
+ const connector = this.activeConnector
524
+ // Already torn down, or the connector can't report a live account — keep the
525
+ // conservative no-op (never risk a spurious disconnect).
526
+ if (!connector?.getLiveAccount) return
527
+ // The active namespace must still be the one we captured; a switch/teardown
528
+ // in flight supersedes this check.
529
+ if (
530
+ this.sessionManager.getSession().activeNetwork?.namespace !==
531
+ activeNamespace
532
+ ) {
533
+ return
534
+ }
535
+ let liveActiveAccount: string | null
536
+ try {
537
+ liveActiveAccount = await connector.getLiveAccount(activeNamespace)
538
+ } catch {
539
+ return
540
+ }
541
+ // Active namespace still connected → genuine partial change → leave session.
542
+ if (liveActiveAccount) return
543
+ // Re-check the connector is still live after the await (guards a race with a
544
+ // concurrent teardown/disconnect that already cleared it).
545
+ if (this.activeConnector === null) return
546
+ // Surface the wallet-initiated disconnect for observability BEFORE clearing:
547
+ // the active namespace here emits no disconnect of its own (that's the whole
548
+ // reason we detect it from a secondary namespace), so without this the
549
+ // `walletDisconnected` telemetry the observer forwards to RUM would never
550
+ // fire for these wallets. Mirrors the lifecycle-bridge's emit for wallets
551
+ // whose active namespace DOES signal a disconnect.
552
+ this.eventManager.emit('walletDisconnected', {
553
+ connectionMode,
554
+ walletId,
555
+ namespace: activeNamespace
556
+ })
557
+ // Whole wallet is gone → run the wallet-initiated disconnect path.
558
+ this.handleAccountsChanged([], activeNamespace)
559
+ }
560
+
561
+ /**
562
+ * After a real account switch on `changedNamespace`, re-read the LIVE account
563
+ * of every OTHER connected namespace and fold any that changed into the
564
+ * session in ONE update. Sibling namespaces (e.g. a MetaMask Solana Snap
565
+ * address derived from the EVM account) change with the underlying account but
566
+ * emit no `accountsChanged`, and their live read can LAG the firing namespace's
567
+ * event while the wallet re-derives — so poll each sibling up to
568
+ * `SIBLING_REFRESH_MAX_ATTEMPTS` times, `SIBLING_REFRESH_DELAY_MS` apart,
569
+ * stopping a sibling the moment its live address differs from the stored one.
570
+ *
571
+ * Loop-safe: writes addresses DIRECTLY via the session manager (never
572
+ * re-dispatches a per-namespace accountsChanged), and only emits when at least
573
+ * one sibling actually changed — so refreshing B here cannot re-enter this for
574
+ * A and recurse. Generation-guarded so a newer switch / teardown / disconnect
575
+ * supersedes an older still-polling refresh before it writes.
576
+ */
577
+ private async refreshSiblingNamespaces(
578
+ changedNamespace: Namespace
579
+ ): Promise<void> {
580
+ const connector = this.activeConnector
581
+ if (!connector?.getLiveAccount) return
582
+ const gen = ++this.siblingRefreshGeneration
583
+
584
+ const namespaceForNetworkId = (
585
+ networkId: NetworkId
586
+ ): Namespace | undefined =>
587
+ this.networks.find(n => n.id === networkId)?.namespace
588
+
589
+ // Sibling = every connected namespace except the one that just fired.
590
+ const initial = this.sessionManager.getSession()
591
+ const siblings = new Set<Namespace>()
592
+ for (const addr of initial.availableAddresses) {
593
+ const ns = namespaceForNetworkId(addr.networkId)
594
+ if (ns && ns !== changedNamespace) siblings.add(ns)
595
+ }
596
+ if (siblings.size === 0) return
597
+
598
+ const equal = (ns: Namespace, a: string, b: string): boolean =>
599
+ ns === 'eip155' ? a.toLowerCase() === b.toLowerCase() : a === b
600
+
601
+ const storedFor = (ns: Namespace): string | undefined => {
602
+ const s = this.sessionManager.getSession()
603
+ for (const addr of s.availableAddresses) {
604
+ if (namespaceForNetworkId(addr.networkId) === ns) return addr.address
605
+ }
606
+ return undefined
607
+ }
608
+
609
+ const resolved = new Map<Namespace, string>()
610
+ const pending = new Set(siblings)
611
+
612
+ for (
613
+ let attempt = 0;
614
+ attempt < SIBLING_REFRESH_MAX_ATTEMPTS && pending.size > 0;
615
+ attempt++
616
+ ) {
617
+ // Superseded (newer switch), disconnected, or connector swapped → abandon.
618
+ if (
619
+ gen !== this.siblingRefreshGeneration ||
620
+ this.activeConnector !== connector
621
+ )
622
+ return
623
+ for (const ns of [...pending]) {
624
+ let live: string | null = null
625
+ try {
626
+ live = await connector.getLiveAccount!(ns)
627
+ } catch {
628
+ live = null
629
+ }
630
+ if (
631
+ gen !== this.siblingRefreshGeneration ||
632
+ this.activeConnector !== connector
633
+ )
634
+ return
635
+ if (!live) continue
636
+ const stored = storedFor(ns)
637
+ if (stored != null && !equal(ns, stored, live)) {
638
+ resolved.set(ns, live)
639
+ pending.delete(ns)
640
+ }
641
+ // stored == live (not yet re-derived) → keep polling until the cap.
642
+ }
643
+ if (pending.size === 0) break
644
+ await new Promise<void>(r => setTimeout(r, SIBLING_REFRESH_DELAY_MS))
645
+ }
646
+
647
+ if (resolved.size === 0) return
648
+ if (
649
+ gen !== this.siblingRefreshGeneration ||
650
+ this.activeConnector !== connector
290
651
  )
652
+ return
653
+
654
+ const session = this.sessionManager.getSession()
655
+ const availableAddresses = session.availableAddresses.map(addr => {
656
+ const ns = namespaceForNetworkId(addr.networkId)
657
+ const next = ns ? resolved.get(ns) : undefined
658
+ return next ? { ...addr, address: next } : addr
659
+ })
660
+ // If the ACTIVE network's namespace was among the refreshed siblings, move
661
+ // activeAddress too (e.g. a Solana-active session whose Solana address just
662
+ // followed an EVM switch).
663
+ const activeNs = session.activeNetwork?.namespace
664
+ const activeAddress =
665
+ activeNs && resolved.has(activeNs)
666
+ ? resolved.get(activeNs)!
667
+ : session.activeAddress
668
+
669
+ this.sessionManager.updateSession({ activeAddress, availableAddresses })
670
+ this.eventManager.emit('sessionChanged', {
671
+ session: this.sessionManager.getSession()
672
+ })
291
673
  }
292
674
 
293
675
  private async connectWithURIPoll(
@@ -486,14 +868,32 @@ export class ConnectionService {
486
868
  await this.activeConnector.disconnect()
487
869
  }
488
870
  } 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 })
871
+ this.finishDisconnect()
494
872
  }
495
873
  }
496
874
 
875
+ /**
876
+ * Clears core's own local state (session, active connector reference,
877
+ * lifecycle subscription) and emits the disconnect events. Does NOT itself
878
+ * call the connector's `disconnect()` — that happens in each CALLER,
879
+ * because the two callers need different semantics: the public
880
+ * `disconnect()` above awaits it (the caller may care whether the wallet
881
+ * side actually tore down); the wallet-initiated path in
882
+ * `handleAccountsChanged` fires it and swallows the result (the wallet
883
+ * already ended the session on its own side, but the connector's own
884
+ * LOCAL state — e.g. a cached account/uuid — still needs clearing, or a
885
+ * later connect to a different wallet reads it as stale and never
886
+ * rebinds).
887
+ */
888
+ private finishDisconnect(): void {
889
+ this.teardownLifecycle()
890
+ this.activeConnector = null
891
+ this.sessionManager.clearSession()
892
+ const session = this.sessionManager.getSession()
893
+ this.eventManager.emit('disconnected', undefined)
894
+ this.eventManager.emit('sessionChanged', { session })
895
+ }
896
+
497
897
  getConnectionURI(): string | undefined {
498
898
  if (
499
899
  this.activeConnector &&
@@ -234,4 +234,37 @@ describe('NetworkSwitchService', () => {
234
234
  expect(events[0].data.operation).toBe('switchNetwork')
235
235
  expect(events[0].data.chainId).toBe('eip155:137')
236
236
  })
237
+
238
+ it('includes the new address on the emitted networkSwitched event', async () => {
239
+ sessionManager.updateSession({
240
+ connectionMode: 'injected',
241
+ activeWallet: {
242
+ id: 'metamask',
243
+ name: 'MetaMask',
244
+ metadata: {
245
+ icon: 'test',
246
+ downloadUrl: 'test',
247
+ supportedChains: ['eip155:1', 'eip155:137']
248
+ },
249
+ extensionInjectedProvider: {
250
+ supportedNetworkIds: ['eip155:1', 'eip155:137']
251
+ }
252
+ } as any
253
+ })
254
+ mockConnector.switchNetwork = vi.fn().mockResolvedValue({
255
+ address: '0xabc',
256
+ availableAddresses: [{ address: '0xabc', networkId: 'eip155:137' }]
257
+ })
258
+ const switched: Array<{ network: unknown; address?: string }> = []
259
+ eventManager.on('networkSwitched', d => switched.push(d))
260
+
261
+ await networkSwitchService.switchNetwork('eip155:137')
262
+
263
+ expect(switched).toEqual([
264
+ {
265
+ network: expect.objectContaining({ id: 'eip155:137' }),
266
+ address: '0xabc'
267
+ }
268
+ ])
269
+ })
237
270
  })
@@ -188,7 +188,10 @@ export class NetworkSwitchService {
188
188
  })
189
189
  })
190
190
 
191
- this.eventManager.emit('networkSwitched', { network })
191
+ this.eventManager.emit('networkSwitched', {
192
+ network,
193
+ address: result.address
194
+ })
192
195
  this.eventManager.emit('sessionChanged', {
193
196
  session: this.sessionManager.getSession()
194
197
  })
@@ -58,7 +58,10 @@ describe('SignatureService.signMessage', () => {
58
58
  mockConnector = {
59
59
  connect: vi.fn(),
60
60
  disconnect: vi.fn(),
61
- signMessage: vi.fn().mockResolvedValue('0xsignature'),
61
+ signMessage: vi.fn().mockResolvedValue({
62
+ signature: { type: 'standard', signature: '0xsignature' },
63
+ address: '0x123'
64
+ }),
62
65
  sendTransaction: vi.fn(),
63
66
  switchNetwork: vi.fn()
64
67
  }
@@ -168,7 +171,11 @@ describe('SignatureService.signMessage', () => {
168
171
 
169
172
  const result = await signatureService.signMessage('test message')
170
173
 
171
- expect(result).toBe('0xsignature')
174
+ // Core is a passthrough — the connector's full SignatureResult surfaces.
175
+ expect(result).toEqual({
176
+ signature: { type: 'standard', signature: '0xsignature' },
177
+ address: '0x123'
178
+ })
172
179
  // The resolved session provider is threaded to the connector.
173
180
  expect(mockConnector.signMessage).toHaveBeenCalledWith(
174
181
  'test message',
@@ -4,7 +4,7 @@ import type {
4
4
  ExtensionInjectedProvider,
5
5
  IntegratedBrowserInjectedProvider,
6
6
  SupportedProvider,
7
- SignatureType,
7
+ SignatureResult,
8
8
  EIP712TypedData
9
9
  } from '@meshconnect/uwc-types'
10
10
  import type { SessionManager } from '../managers/session-manager'
@@ -42,12 +42,13 @@ export class SignatureService {
42
42
  * Sign a message with the currently connected wallet
43
43
  * @param message The message to sign
44
44
  * @param options Optional cancellation signal
45
- * @returns A promise that resolves to the signature
45
+ * @returns A promise resolving to a {@link SignatureResult} carrying both the
46
+ * signature and the EXACT address it was signed with.
46
47
  */
47
48
  async signMessage(
48
49
  message: string,
49
50
  options?: ServiceOptions
50
- ): Promise<SignatureType> {
51
+ ): Promise<SignatureResult> {
51
52
  options?.signal?.throwIfAborted()
52
53
  const session = validateWalletSession(this.sessionManager, {
53
54
  requireAddress: true
@@ -16,7 +16,7 @@ import type {
16
16
  TransactionResult,
17
17
  NetworkRpcMap,
18
18
  EVMCapabilities,
19
- SignatureType,
19
+ SignatureResult,
20
20
  EIP712TypedData,
21
21
  LogLevel,
22
22
  Logger
@@ -846,12 +846,14 @@ export class UniversalWalletConnector {
846
846
  * Sign a message with the connected wallet
847
847
  * @param message The message to sign
848
848
  * @param options Optional cancellation signal
849
- * @returns A promise that resolves to the signature
849
+ * @returns A promise resolving to a {@link SignatureResult} carrying both the
850
+ * signature and the EXACT address it was signed with. Consumers verifying
851
+ * ownership MUST claim `result.address` rather than a stale session address.
850
852
  */
851
853
  async signMessage(
852
854
  message: string,
853
855
  options?: OperationOptions
854
- ): Promise<SignatureType> {
856
+ ): Promise<SignatureResult> {
855
857
  try {
856
858
  // Validation + funnel instrumentation live in SignatureService (after
857
859
  // validation, at the real wallet boundary) — same layering as connect.
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.10.3'
2
+ export const UWC_CORE_VERSION = '2.0.0'