@meshconnect/uwc-core 1.2.3-snapshot.dcfdbbf → 1.3.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 (54) hide show
  1. package/dist/events.d.ts +40 -5
  2. package/dist/events.d.ts.map +1 -1
  3. package/dist/events.js +4 -0
  4. package/dist/events.js.map +1 -1
  5. package/dist/observability/focus-tracker.d.ts +30 -11
  6. package/dist/observability/focus-tracker.d.ts.map +1 -1
  7. package/dist/observability/focus-tracker.js +54 -18
  8. package/dist/observability/focus-tracker.js.map +1 -1
  9. package/dist/observability/instrument-handoff.d.ts.map +1 -1
  10. package/dist/observability/instrument-handoff.js +25 -6
  11. package/dist/observability/instrument-handoff.js.map +1 -1
  12. package/dist/observability/lifecycle-bridge.d.ts +13 -31
  13. package/dist/observability/lifecycle-bridge.d.ts.map +1 -1
  14. package/dist/observability/lifecycle-bridge.js +92 -167
  15. package/dist/observability/lifecycle-bridge.js.map +1 -1
  16. package/dist/observability/relay-drop-tracker.d.ts +37 -0
  17. package/dist/observability/relay-drop-tracker.d.ts.map +1 -0
  18. package/dist/observability/relay-drop-tracker.js +30 -0
  19. package/dist/observability/relay-drop-tracker.js.map +1 -0
  20. package/dist/observability/telemetry.d.ts +8 -0
  21. package/dist/observability/telemetry.d.ts.map +1 -1
  22. package/dist/observability/telemetry.js +32 -5
  23. package/dist/observability/telemetry.js.map +1 -1
  24. package/dist/services/connection-service.d.ts +0 -43
  25. package/dist/services/connection-service.d.ts.map +1 -1
  26. package/dist/services/connection-service.js +9 -163
  27. package/dist/services/connection-service.js.map +1 -1
  28. package/dist/services/network-switch-service.d.ts.map +1 -1
  29. package/dist/services/network-switch-service.js +1 -4
  30. package/dist/services/network-switch-service.js.map +1 -1
  31. package/dist/universal-wallet-connector.d.ts.map +1 -1
  32. package/dist/universal-wallet-connector.js +18 -11
  33. package/dist/universal-wallet-connector.js.map +1 -1
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/package.json +5 -5
  37. package/src/events.ts +50 -6
  38. package/src/observability/focus-tracker.test.ts +95 -8
  39. package/src/observability/focus-tracker.ts +72 -22
  40. package/src/observability/instrument-handoff.test.ts +62 -0
  41. package/src/observability/instrument-handoff.ts +37 -6
  42. package/src/observability/lifecycle-bridge.test.ts +0 -197
  43. package/src/observability/lifecycle-bridge.ts +99 -211
  44. package/src/observability/relay-drop-tracker.test.ts +84 -0
  45. package/src/observability/relay-drop-tracker.ts +53 -0
  46. package/src/observability/telemetry.test.ts +18 -0
  47. package/src/observability/telemetry.ts +41 -6
  48. package/src/services/connection-service.test.ts +0 -272
  49. package/src/services/connection-service.ts +10 -190
  50. package/src/services/network-switch-service.test.ts +0 -33
  51. package/src/services/network-switch-service.ts +1 -4
  52. package/src/universal-wallet-connector.observability.test.ts +54 -8
  53. package/src/universal-wallet-connector.ts +22 -12
  54. package/src/version.ts +1 -1
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { EventManager } from '../managers/event-manager'
3
+ import { trackRelayDropsDuringHandoff } from './relay-drop-tracker'
4
+
5
+ describe('trackRelayDropsDuringHandoff', () => {
6
+ it('counts each relay drop seen during a WalletConnect handoff', () => {
7
+ const em = new EventManager()
8
+ const tracker = trackRelayDropsDuringHandoff(em, 'walletConnect')
9
+
10
+ expect(tracker.summary()).toEqual({ relayDropCount: 0 })
11
+
12
+ em.emit('relayDisconnected', undefined)
13
+ em.emit('relayDisconnected', undefined)
14
+ expect(tracker.summary()).toEqual({ relayDropCount: 2 })
15
+ })
16
+
17
+ it('reports undefined (not 0) off WalletConnect so the terminal omits the field', () => {
18
+ // A `0` on an injected handoff would read as "a relay we watched, and it
19
+ // held" — but no relay is in play, so the field must be absent entirely.
20
+ const em = new EventManager()
21
+ for (const mode of ['injected', 'tonConnect'] as const) {
22
+ const tracker = trackRelayDropsDuringHandoff(em, mode)
23
+ expect(tracker.summary()).toEqual({ relayDropCount: undefined })
24
+ }
25
+ })
26
+
27
+ it('does NOT subscribe off WalletConnect — a relay flap on the shared bus is ignored', () => {
28
+ const em = new EventManager()
29
+ const tracker = trackRelayDropsDuringHandoff(em, 'injected')
30
+
31
+ // The bus is client-global; a WC relay drop firing while an injected handoff
32
+ // is in flight must not be attributed to it.
33
+ em.emit('relayDisconnected', undefined)
34
+ em.emit('relayDisconnected', undefined)
35
+ expect(tracker.summary()).toEqual({ relayDropCount: undefined })
36
+ })
37
+
38
+ it('stops counting after stop() — drops outside the blind window do not accrue', () => {
39
+ const em = new EventManager()
40
+ const tracker = trackRelayDropsDuringHandoff(em, 'walletConnect')
41
+
42
+ em.emit('relayDisconnected', undefined)
43
+ tracker.stop()
44
+ em.emit('relayDisconnected', undefined)
45
+ em.emit('relayDisconnected', undefined)
46
+
47
+ expect(tracker.summary()).toEqual({ relayDropCount: 1 })
48
+ })
49
+
50
+ it('only counts relayDisconnected — reconnects and other bus events are ignored', () => {
51
+ const em = new EventManager()
52
+ const tracker = trackRelayDropsDuringHandoff(em, 'walletConnect')
53
+
54
+ em.emit('relayDisconnected', undefined)
55
+ em.emit('relayReconnected', undefined)
56
+ em.emit('relayReconnected', undefined)
57
+
58
+ expect(tracker.summary()).toEqual({ relayDropCount: 1 })
59
+ })
60
+
61
+ it('stop() is a safe no-op off WalletConnect (nothing was subscribed)', () => {
62
+ const em = new EventManager()
63
+ const tracker = trackRelayDropsDuringHandoff(em, 'tonConnect')
64
+ expect(() => tracker.stop()).not.toThrow()
65
+ expect(tracker.summary()).toEqual({ relayDropCount: undefined })
66
+ })
67
+
68
+ it('two concurrent WalletConnect handoffs count independently and unsubscribe independently', () => {
69
+ // The bus is shared; each tracker owns its own listener, so stopping one
70
+ // must not silence the other (and both saw the same drop).
71
+ const em = new EventManager()
72
+ const a = trackRelayDropsDuringHandoff(em, 'walletConnect')
73
+ const b = trackRelayDropsDuringHandoff(em, 'walletConnect')
74
+
75
+ em.emit('relayDisconnected', undefined)
76
+ expect(a.summary()).toEqual({ relayDropCount: 1 })
77
+ expect(b.summary()).toEqual({ relayDropCount: 1 })
78
+
79
+ a.stop()
80
+ em.emit('relayDisconnected', undefined)
81
+ expect(a.summary()).toEqual({ relayDropCount: 1 })
82
+ expect(b.summary()).toEqual({ relayDropCount: 2 })
83
+ })
84
+ })
@@ -0,0 +1,53 @@
1
+ import type { ConnectionMode } from '@meshconnect/uwc-types'
2
+ import type { EventManager } from '../managers/event-manager'
3
+
4
+ /** Handle returned by `trackRelayDropsDuringHandoff`. */
5
+ export interface RelayDropHandoffTracker {
6
+ /**
7
+ * Remove the relay-drop subscription. Always call on settle (no-op off
8
+ * WalletConnect, where nothing was subscribed).
9
+ */
10
+ stop: () => void
11
+ /**
12
+ * Relay-drop count at call time, or `undefined` off WalletConnect. Undefined
13
+ * (not `0`) so the terminal's `compactUndefined` omits the field entirely — a
14
+ * `0` on an injected/TON handoff would falsely imply "a WC relay we watched,
15
+ * and it held", when in fact no relay was in play.
16
+ */
17
+ summary: () => { relayDropCount: number | undefined }
18
+ }
19
+
20
+ /**
21
+ * While a wallet handoff is pending, count relay-socket drops seen on the UWC
22
+ * event bus. The WalletConnect client emits `relayDisconnected` once per outage
23
+ * (see `attachRelayLifecycleListeners`); this is the CARE-263 signal — an
24
+ * iOS-suspended WebView drops the relay socket mid-handoff and strands the
25
+ * in-flight sign/connect response until WC core's slow heartbeat reconnects, so
26
+ * the drop count on a terminal explains a long/failed handoff that focus timing
27
+ * alone can't.
28
+ *
29
+ * WalletConnect ONLY. The relay bus is client-global, so an injected or TON
30
+ * handoff that happens to be in flight during a WC relay flap would otherwise
31
+ * misattribute the drop; off WalletConnect this is a no-op tracker whose
32
+ * `summary()` reports `undefined`. Mirrors `trackFocusDuringHandoff`: attach
33
+ * right after `awaitingWallet`, and `stop()` (called the instant the op settles)
34
+ * removes the subscription so drops are only counted during the blind window.
35
+ */
36
+ export function trackRelayDropsDuringHandoff(
37
+ eventManager: EventManager,
38
+ connectionMode: ConnectionMode
39
+ ): RelayDropHandoffTracker {
40
+ if (connectionMode !== 'walletConnect') {
41
+ return { stop: () => {}, summary: () => ({ relayDropCount: undefined }) }
42
+ }
43
+
44
+ let relayDropCount = 0
45
+ const off = eventManager.on('relayDisconnected', () => {
46
+ relayDropCount++
47
+ })
48
+
49
+ return {
50
+ stop: off,
51
+ summary: () => ({ relayDropCount })
52
+ }
53
+ }
@@ -520,6 +520,24 @@ describe('normalizeEvent', () => {
520
520
  expect(JSON.stringify(out)).not.toContain('0xAbC123')
521
521
  })
522
522
 
523
+ it('normalizes relayDisconnected as an unclassified walletConnect signal (void, no reason)', () => {
524
+ // The relayer carries no payload, so the event is void — never an error, and
525
+ // observational (no failureKind, so it can't inflate failure metrics).
526
+ const out = normalizeEvent('relayDisconnected', undefined, SDK_SESSION, NOW)
527
+ expect(out.connectionMode).toBe('walletConnect')
528
+ expect(out.source).toBe('provider')
529
+ expect(out.error).toBeUndefined()
530
+ expect(out.failureKind).toBeUndefined()
531
+ })
532
+
533
+ it('normalizes relayReconnected as an unclassified walletConnect signal', () => {
534
+ const out = normalizeEvent('relayReconnected', undefined, SDK_SESSION, NOW)
535
+ expect(out.name).toBe('relayReconnected')
536
+ expect(out.connectionMode).toBe('walletConnect')
537
+ expect(out.source).toBe('provider')
538
+ expect(out.failureKind).toBeUndefined()
539
+ })
540
+
523
541
  it('normalizes walletConnectInitError as a walletConnect client_init_failed, message scrubbed', () => {
524
542
  const out = normalizeEvent(
525
543
  'walletConnectInitError',
@@ -154,6 +154,14 @@ export interface UWCTelemetryEvent {
154
154
  * handoffCovered to avoid double-counting.
155
155
  */
156
156
  handoffCovered?: boolean | undefined
157
+ /** `pageVisibleDuringHandoff` only: how long the page was hidden for that segment (ms). */
158
+ hiddenMs?: number | undefined
159
+ /** Handoff terminals: cumulative time the page was backgrounded during the handoff (ms). */
160
+ backgroundedMs?: number | undefined
161
+ /** Handoff terminals: how many times the page was backgrounded during the handoff. */
162
+ backgroundCount?: number | undefined
163
+ /** Handoff terminals, WalletConnect only: relay-socket drops seen while the handoff was pending. */
164
+ relayDropCount?: number | undefined
157
165
  }
158
166
 
159
167
  /**
@@ -362,7 +370,10 @@ function buildTelemetryEvent<K extends UWCEventName>(
362
370
  durationMs: d.durationMs,
363
371
  timedOut: d.timedOut,
364
372
  timeoutMs: d.timeoutMs,
365
- targetAlreadyActive: d.targetAlreadyActive
373
+ targetAlreadyActive: d.targetAlreadyActive,
374
+ backgroundedMs: d.backgroundedMs,
375
+ backgroundCount: d.backgroundCount,
376
+ relayDropCount: d.relayDropCount
366
377
  }
367
378
  }
368
379
  case 'walletRejected': {
@@ -381,7 +392,10 @@ function buildTelemetryEvent<K extends UWCEventName>(
381
392
  targetAlreadyActive: d.targetAlreadyActive,
382
393
  errorCode: d.errorCode,
383
394
  failureKind: d.failureKind,
384
- source: d.source
395
+ source: d.source,
396
+ backgroundedMs: d.backgroundedMs,
397
+ backgroundCount: d.backgroundCount,
398
+ relayDropCount: d.relayDropCount
385
399
  }
386
400
  }
387
401
  case 'walletTimedOut': {
@@ -396,7 +410,10 @@ function buildTelemetryEvent<K extends UWCEventName>(
396
410
  handoffId: d.handoffId,
397
411
  durationMs: d.durationMs,
398
412
  timeoutMs: d.timeoutMs,
399
- targetAlreadyActive: d.targetAlreadyActive
413
+ targetAlreadyActive: d.targetAlreadyActive,
414
+ backgroundedMs: d.backgroundedMs,
415
+ backgroundCount: d.backgroundCount,
416
+ relayDropCount: d.relayDropCount
400
417
  }
401
418
  }
402
419
  case 'walletFailed': {
@@ -416,7 +433,10 @@ function buildTelemetryEvent<K extends UWCEventName>(
416
433
  errorCode: d.errorCode,
417
434
  failureKind: d.failureKind,
418
435
  source: d.source,
419
- error: { type: d.error.type, message: scrubMessage(d.error.message) }
436
+ error: { type: d.error.type, message: scrubMessage(d.error.message) },
437
+ backgroundedMs: d.backgroundedMs,
438
+ backgroundCount: d.backgroundCount,
439
+ relayDropCount: d.relayDropCount
420
440
  }
421
441
  }
422
442
  case 'capabilitiesUpdated': {
@@ -448,8 +468,15 @@ function buildTelemetryEvent<K extends UWCEventName>(
448
468
  }
449
469
  case 'pageHiddenDuringHandoff':
450
470
  case 'pageVisibleDuringHandoff': {
451
- const d = data as UWCEventMap['pageHiddenDuringHandoff']
452
- return { ...base, handoffId: d.handoffId, platform: d.platform }
471
+ // Cast to the visible shape (superset). `hiddenMs` is absent on the hidden
472
+ // event undefined dropped by compactUndefined.
473
+ const d = data as UWCEventMap['pageVisibleDuringHandoff']
474
+ return {
475
+ ...base,
476
+ handoffId: d.handoffId,
477
+ platform: d.platform,
478
+ hiddenMs: d.hiddenMs
479
+ }
453
480
  }
454
481
  case 'bridgeError': {
455
482
  const d = data as UWCEventMap['bridgeError']
@@ -473,6 +500,14 @@ function buildTelemetryEvent<K extends UWCEventName>(
473
500
  error: { type: d.error.type, message: scrubMessage(d.error.message) }
474
501
  }
475
502
  }
503
+ case 'relayDisconnected':
504
+ case 'relayReconnected': {
505
+ // Relay transport lifecycle (WalletConnect). Observational — NOT a failure,
506
+ // so deliberately no `failureKind` (never inflates failure metrics). The
507
+ // relayer carries no payload; pair the two by sdkSessionId + timestamp for
508
+ // the dead-window duration.
509
+ return { ...base, connectionMode: 'walletConnect', source: 'provider' }
510
+ }
476
511
  case 'walletConnectInitError': {
477
512
  const d = data as UWCEventMap['walletConnectInitError']
478
513
  // SignClient.init failed — the WC client never came up. Distinct from
@@ -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
  })