@meshconnect/uwc-core 1.0.3 → 1.1.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 (121) hide show
  1. package/README.md +247 -76
  2. package/dist/events.d.ts +241 -5
  3. package/dist/events.d.ts.map +1 -1
  4. package/dist/events.js +77 -1
  5. package/dist/events.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/logger/create-logger.d.ts +32 -0
  11. package/dist/logger/create-logger.d.ts.map +1 -0
  12. package/dist/logger/create-logger.js +79 -0
  13. package/dist/logger/create-logger.js.map +1 -0
  14. package/dist/managers/event-manager.d.ts.map +1 -1
  15. package/dist/managers/event-manager.js +5 -1
  16. package/dist/managers/event-manager.js.map +1 -1
  17. package/dist/observability/connect-observer.d.ts +33 -0
  18. package/dist/observability/connect-observer.d.ts.map +1 -0
  19. package/dist/observability/connect-observer.js +115 -0
  20. package/dist/observability/connect-observer.js.map +1 -0
  21. package/dist/observability/focus-tracker.d.ts +42 -0
  22. package/dist/observability/focus-tracker.d.ts.map +1 -0
  23. package/dist/observability/focus-tracker.js +57 -0
  24. package/dist/observability/focus-tracker.js.map +1 -0
  25. package/dist/observability/instrument-handoff.d.ts +54 -0
  26. package/dist/observability/instrument-handoff.d.ts.map +1 -0
  27. package/dist/observability/instrument-handoff.js +173 -0
  28. package/dist/observability/instrument-handoff.js.map +1 -0
  29. package/dist/observability/lifecycle-bridge.d.ts +69 -0
  30. package/dist/observability/lifecycle-bridge.d.ts.map +1 -0
  31. package/dist/observability/lifecycle-bridge.js +155 -0
  32. package/dist/observability/lifecycle-bridge.js.map +1 -0
  33. package/dist/observability/scrub.d.ts +31 -0
  34. package/dist/observability/scrub.d.ts.map +1 -0
  35. package/dist/observability/scrub.js +167 -0
  36. package/dist/observability/scrub.js.map +1 -0
  37. package/dist/observability/telemetry.d.ts +157 -0
  38. package/dist/observability/telemetry.d.ts.map +1 -0
  39. package/dist/observability/telemetry.js +373 -0
  40. package/dist/observability/telemetry.js.map +1 -0
  41. package/dist/services/connection-service.d.ts +23 -1
  42. package/dist/services/connection-service.d.ts.map +1 -1
  43. package/dist/services/connection-service.js +128 -19
  44. package/dist/services/connection-service.js.map +1 -1
  45. package/dist/services/network-switch-service.d.ts +2 -1
  46. package/dist/services/network-switch-service.d.ts.map +1 -1
  47. package/dist/services/network-switch-service.js +45 -16
  48. package/dist/services/network-switch-service.js.map +1 -1
  49. package/dist/services/signature-service.d.ts +14 -7
  50. package/dist/services/signature-service.d.ts.map +1 -1
  51. package/dist/services/signature-service.js +56 -58
  52. package/dist/services/signature-service.js.map +1 -1
  53. package/dist/services/transaction-service.d.ts +19 -5
  54. package/dist/services/transaction-service.d.ts.map +1 -1
  55. package/dist/services/transaction-service.js +55 -38
  56. package/dist/services/transaction-service.js.map +1 -1
  57. package/dist/universal-wallet-connector.d.ts +56 -1
  58. package/dist/universal-wallet-connector.d.ts.map +1 -1
  59. package/dist/universal-wallet-connector.js +207 -110
  60. package/dist/universal-wallet-connector.js.map +1 -1
  61. package/dist/utils/id.d.ts +2 -0
  62. package/dist/utils/id.d.ts.map +1 -0
  63. package/dist/utils/id.js +41 -0
  64. package/dist/utils/id.js.map +1 -0
  65. package/dist/utils/resolve-session-provider.d.ts +10 -0
  66. package/dist/utils/resolve-session-provider.d.ts.map +1 -0
  67. package/dist/utils/resolve-session-provider.js +21 -0
  68. package/dist/utils/resolve-session-provider.js.map +1 -0
  69. package/dist/utils/to-wallet-error.d.ts +35 -0
  70. package/dist/utils/to-wallet-error.d.ts.map +1 -0
  71. package/dist/utils/to-wallet-error.js +130 -0
  72. package/dist/utils/to-wallet-error.js.map +1 -0
  73. package/dist/utils/validate-wallet-session.d.ts +15 -0
  74. package/dist/utils/validate-wallet-session.d.ts.map +1 -0
  75. package/dist/utils/validate-wallet-session.js +29 -0
  76. package/dist/utils/validate-wallet-session.js.map +1 -0
  77. package/dist/version.d.ts +2 -0
  78. package/dist/version.d.ts.map +1 -0
  79. package/dist/version.js +3 -0
  80. package/dist/version.js.map +1 -0
  81. package/package.json +6 -6
  82. package/src/events.ts +326 -14
  83. package/src/index.ts +10 -0
  84. package/src/logger/create-logger.test.ts +126 -0
  85. package/src/logger/create-logger.ts +112 -0
  86. package/src/managers/event-manager.test.ts +55 -0
  87. package/src/managers/event-manager.ts +5 -1
  88. package/src/observability/connect-observer.test.ts +214 -0
  89. package/src/observability/connect-observer.ts +128 -0
  90. package/src/observability/focus-tracker.test.ts +191 -0
  91. package/src/observability/focus-tracker.ts +96 -0
  92. package/src/observability/instrument-handoff.test.ts +397 -0
  93. package/src/observability/instrument-handoff.ts +231 -0
  94. package/src/observability/lifecycle-bridge.test.ts +398 -0
  95. package/src/observability/lifecycle-bridge.ts +216 -0
  96. package/src/observability/scrub.test.ts +183 -0
  97. package/src/observability/scrub.ts +174 -0
  98. package/src/observability/telemetry.test.ts +549 -0
  99. package/src/observability/telemetry.ts +546 -0
  100. package/src/services/connection-service.test.ts +261 -1
  101. package/src/services/connection-service.ts +167 -38
  102. package/src/services/network-switch-service.test.ts +51 -1
  103. package/src/services/network-switch-service.ts +56 -24
  104. package/src/services/signature-service.test.ts +181 -76
  105. package/src/services/signature-service.ts +75 -85
  106. package/src/services/transaction-service.test.ts +270 -69
  107. package/src/services/transaction-service.ts +74 -51
  108. package/src/universal-wallet-connector.instrumentation-guard.test.ts +199 -0
  109. package/src/universal-wallet-connector.observability.test.ts +610 -0
  110. package/src/universal-wallet-connector.test.ts +27 -146
  111. package/src/universal-wallet-connector.ts +279 -142
  112. package/src/utils/id.test.ts +15 -0
  113. package/src/utils/id.ts +48 -0
  114. package/src/utils/resolve-session-provider.test.ts +54 -0
  115. package/src/utils/resolve-session-provider.ts +25 -0
  116. package/src/utils/to-wallet-error.test.ts +181 -0
  117. package/src/utils/to-wallet-error.ts +152 -0
  118. package/src/utils/validate-wallet-session.test.ts +77 -0
  119. package/src/utils/validate-wallet-session.ts +40 -0
  120. package/src/version.test.ts +11 -0
  121. package/src/version.ts +2 -0
@@ -8,6 +8,8 @@ import type {
8
8
  ConnectionMode,
9
9
  Connector
10
10
  } from '@meshconnect/uwc-types'
11
+ import { NoCommonNetworkError } from '@meshconnect/uwc-types'
12
+ import type { WalletLifecycleEvent } from '@meshconnect/uwc-types'
11
13
 
12
14
  describe('ConnectionService', () => {
13
15
  let connectionService: ConnectionService
@@ -81,7 +83,8 @@ describe('ConnectionService', () => {
81
83
  sessionManager,
82
84
  connectors,
83
85
  false,
84
- eventManager
86
+ eventManager,
87
+ 60_000
85
88
  )
86
89
  })
87
90
 
@@ -123,6 +126,28 @@ describe('ConnectionService', () => {
123
126
  }).toThrow('Network eip155:8453 is not in the configured networks')
124
127
  })
125
128
 
129
+ it('throws a typed NoCommonNetworkError with both CAIP-2 lists (delta A)', () => {
130
+ // No networkId arg → the auto-select branch. The wallet supports only a
131
+ // chain that isn't configured, so there is no overlap: the #1 prod failure.
132
+ const mockProvider = {
133
+ supportedNetworkIds: ['solana:101', 'tron:0x2b6653dc']
134
+ }
135
+ let thrown: unknown
136
+ try {
137
+ connectionService['validateAndDetermineNetwork'](
138
+ undefined,
139
+ mockProvider as any
140
+ )
141
+ } catch (e) {
142
+ thrown = e
143
+ }
144
+ expect(thrown).toBeInstanceOf(NoCommonNetworkError)
145
+ const err = thrown as NoCommonNetworkError
146
+ expect(err.walletNetworkIds).toEqual(['solana:101', 'tron:0x2b6653dc'])
147
+ // configuredNetworkIds is whatever the suite configured (includes eip155:1).
148
+ expect(err.configuredNetworkIds).toContain('eip155:1')
149
+ })
150
+
126
151
  it('should get connector by mode', () => {
127
152
  const connector = connectionService['getConnector']('injected')
128
153
  expect(connector).toBe(mockConnector)
@@ -193,6 +218,70 @@ describe('ConnectionService', () => {
193
218
  expect(connectingListener).not.toHaveBeenCalled()
194
219
  })
195
220
 
221
+ it('does NOT emit awaitingWallet when validation throws (pre-wallet failure stays out of the funnel)', async () => {
222
+ const awaiting = vi.fn()
223
+ const failed = vi.fn()
224
+ eventManager.on('awaitingWallet', awaiting)
225
+ eventManager.on('walletFailed', failed)
226
+
227
+ await expect(
228
+ connectionService.connect('injected', 'no-such-wallet', 'eip155:1')
229
+ ).rejects.toThrow()
230
+
231
+ expect(awaiting).not.toHaveBeenCalled()
232
+ expect(failed).not.toHaveBeenCalled()
233
+ })
234
+
235
+ it('emits awaitingWallet + walletSucceeded around the wallet call, with resolved facets', async () => {
236
+ wallets[0].extensionInjectedProvider = {
237
+ supportedNetworkIds: ['eip155:1']
238
+ } as any
239
+ const events: Array<{ name: string; data: any }> = []
240
+ eventManager.on('awaitingWallet', d =>
241
+ events.push({ name: 'awaitingWallet', data: d })
242
+ )
243
+ eventManager.on('walletSucceeded', d =>
244
+ events.push({ name: 'walletSucceeded', data: d })
245
+ )
246
+ mockConnector.connect = vi.fn().mockResolvedValue({
247
+ networkId: 'eip155:1',
248
+ address: '0x1234',
249
+ availableAddresses: [{ address: '0x1234', networkId: 'eip155:1' }]
250
+ })
251
+
252
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
253
+
254
+ expect(events.map(e => e.name)).toEqual([
255
+ 'awaitingWallet',
256
+ 'walletSucceeded'
257
+ ])
258
+ const awaiting = events[0].data
259
+ expect(awaiting.operation).toBe('connect')
260
+ expect(awaiting.namespace).toBe('eip155')
261
+ expect(awaiting.chainId).toBe('eip155:1')
262
+ expect(awaiting.handoffId).toBe(events[1].data.handoffId)
263
+ })
264
+
265
+ it('emits awaitingWallet + walletFailed when the connector call throws', async () => {
266
+ wallets[0].extensionInjectedProvider = {
267
+ supportedNetworkIds: ['eip155:1']
268
+ } as any
269
+ const awaiting = vi.fn()
270
+ const failed = vi.fn()
271
+ eventManager.on('awaitingWallet', awaiting)
272
+ eventManager.on('walletFailed', failed)
273
+ mockConnector.connect = vi
274
+ .fn()
275
+ .mockRejectedValue(new Error('wallet exploded'))
276
+
277
+ await expect(
278
+ connectionService.connect('injected', 'metamask', 'eip155:1')
279
+ ).rejects.toThrow('wallet exploded')
280
+
281
+ expect(awaiting).toHaveBeenCalledTimes(1)
282
+ expect(failed).toHaveBeenCalledTimes(1)
283
+ })
284
+
196
285
  it('rejects mid-flight when AbortSignal fires during connector.connect', async () => {
197
286
  wallets[0].extensionInjectedProvider = {
198
287
  supportedNetworkIds: ['eip155:1']
@@ -604,4 +693,175 @@ describe('ConnectionService', () => {
604
693
  expect(connectionService['activeConnector']).toBeNull()
605
694
  })
606
695
  })
696
+
697
+ describe('lifecycle wiring (P2)', () => {
698
+ beforeEach(() => {
699
+ wallets[0].extensionInjectedProvider = {
700
+ supportedNetworkIds: ['eip155:1']
701
+ } as any
702
+ mockConnector.connect = vi.fn().mockResolvedValue({
703
+ networkId: 'eip155:1',
704
+ address: '0x1234',
705
+ availableAddresses: [{ address: '0x1234', networkId: 'eip155:1' }]
706
+ })
707
+ })
708
+
709
+ it('subscribes to connector lifecycle after connect and tears down on disconnect', async () => {
710
+ let emit: ((e: WalletLifecycleEvent) => void) | undefined
711
+ let receivedContext: unknown
712
+ let unsubbed = false
713
+ mockConnector.onLifecycleEvent = (
714
+ l: (e: WalletLifecycleEvent) => void,
715
+ context: unknown
716
+ ) => {
717
+ emit = l
718
+ receivedContext = context
719
+ return () => {
720
+ unsubbed = true
721
+ }
722
+ }
723
+ const accounts: unknown[] = []
724
+ eventManager.on('walletAccountChanged', d => accounts.push(d))
725
+
726
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
727
+ emit?.({ type: 'accountChanged' })
728
+ expect(accounts).toHaveLength(1)
729
+ // The core TELLS the connector which session it is attaching for — the
730
+ // connector must never infer it from ambient state.
731
+ expect(receivedContext).toEqual({
732
+ connectionMode: 'injected',
733
+ namespace: 'eip155',
734
+ walletId: 'metamask'
735
+ })
736
+
737
+ await connectionService.disconnect()
738
+ expect(unsubbed).toBe(true)
739
+ })
740
+
741
+ it('teardownLifecycle() releases the subscription without disconnecting (the dispose path)', async () => {
742
+ let emit: ((e: WalletLifecycleEvent) => void) | undefined
743
+ let unsubCount = 0
744
+ mockConnector.onLifecycleEvent = (
745
+ l: (e: WalletLifecycleEvent) => void
746
+ ) => {
747
+ emit = l
748
+ return () => {
749
+ unsubCount++
750
+ }
751
+ }
752
+ const accounts: unknown[] = []
753
+ eventManager.on('walletAccountChanged', d => accounts.push(d))
754
+
755
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
756
+ emit?.({ type: 'accountChanged' })
757
+ expect(accounts).toHaveLength(1)
758
+
759
+ // dispose() takes THIS path, not disconnect() — the live provider listeners
760
+ // must still be released, or they leak (the zombie-listener bug class).
761
+ connectionService.teardownLifecycle()
762
+ expect(unsubCount).toBe(1)
763
+
764
+ // Idempotent: a later disconnect() (which also tears down) is a safe no-op.
765
+ connectionService.teardownLifecycle()
766
+ expect(unsubCount).toBe(1)
767
+ })
768
+
769
+ it('emits lifecycleTelemetryUnavailable when the connector cannot observe', async () => {
770
+ // mockConnector has no onLifecycleEvent in this case.
771
+ const unavail: unknown[] = []
772
+ eventManager.on('lifecycleTelemetryUnavailable', d => unavail.push(d))
773
+
774
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
775
+
776
+ expect(unavail).toHaveLength(1)
777
+ })
778
+
779
+ it('reattachLifecycle() swaps the subscription for the current session (cross-namespace switch path)', async () => {
780
+ // After a cross-namespace switchNetwork the live provider changes; the
781
+ // façade calls this so lifecycle follows the ACTIVE namespace instead of
782
+ // staying pinned to the connect-time provider.
783
+ const subscribers: Array<(e: WalletLifecycleEvent) => void> = []
784
+ let unsubCount = 0
785
+ mockConnector.onLifecycleEvent = (
786
+ l: (e: WalletLifecycleEvent) => void
787
+ ) => {
788
+ subscribers.push(l)
789
+ return () => {
790
+ unsubCount++
791
+ }
792
+ }
793
+
794
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
795
+ expect(subscribers).toHaveLength(1)
796
+
797
+ connectionService.reattachLifecycle()
798
+
799
+ // Old subscription released, a fresh one attached — never two live at once.
800
+ expect(unsubCount).toBe(1)
801
+ expect(subscribers).toHaveLength(2)
802
+ })
803
+
804
+ it('releases activeConnector when an abort lands AFTER the wallet settled (post-funnel gap)', async () => {
805
+ // raceAbort covers aborts DURING the wallet call; this covers the window
806
+ // between the funnel resolving and the trailing throwIfAborted. The
807
+ // half-attached connector must be released, or a later caller reads a
808
+ // stale connection URI from an attempt that never became a session.
809
+ const controller = new AbortController()
810
+ mockConnector.connect = vi.fn().mockResolvedValue({
811
+ networkId: 'eip155:1',
812
+ address: '0x1234',
813
+ availableAddresses: []
814
+ })
815
+ mockConnector.getConnectionURI = vi.fn().mockReturnValue('uri://stale')
816
+ // walletSucceeded is emitted synchronously after the wallet settles and
817
+ // before the post-funnel abort check — abort exactly in that window.
818
+ eventManager.on('walletSucceeded', () => controller.abort())
819
+
820
+ await expect(
821
+ connectionService.connect('injected', 'metamask', 'eip155:1', {
822
+ signal: controller.signal
823
+ })
824
+ ).rejects.toMatchObject({ name: 'AbortError' })
825
+
826
+ expect(connectionService.getConnectionURI()).toBeUndefined()
827
+ // And the session was never mutated by the aborted attempt.
828
+ expect(sessionManager.getSession().activeWallet).toBeNull()
829
+ })
830
+
831
+ it('reattachLifecycle() seeds echo suppression for the just-active chain', async () => {
832
+ // The reattach happens AFTER a switch completed, so the fresh lifecycle
833
+ // closure never saw that switch's bus events — without a seed, the new
834
+ // namespace's provider echo would surface as a wallet-initiated change.
835
+ const subscribers: Array<(e: WalletLifecycleEvent) => void> = []
836
+ mockConnector.onLifecycleEvent = (
837
+ l: (e: WalletLifecycleEvent) => void
838
+ ) => {
839
+ subscribers.push(l)
840
+ return () => {}
841
+ }
842
+ const seen: unknown[] = []
843
+ eventManager.on('walletChainChanged', d => seen.push(d))
844
+
845
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
846
+ connectionService.reattachLifecycle()
847
+
848
+ // The re-attached provider echoes the just-completed switch → suppressed…
849
+ subscribers[1]!({ type: 'chainChanged', chainId: 'eip155:1' })
850
+ expect(seen).toHaveLength(0)
851
+
852
+ // …and consumed: a later same-chain change is genuine and surfaces.
853
+ subscribers[1]!({ type: 'chainChanged', chainId: 'eip155:1' })
854
+ expect(seen).toHaveLength(1)
855
+ })
856
+
857
+ it('reattachLifecycle() is a safe no-op when nothing is connected', () => {
858
+ const unavail: unknown[] = []
859
+ eventManager.on('lifecycleTelemetryUnavailable', d => unavail.push(d))
860
+
861
+ expect(() => connectionService.reattachLifecycle()).not.toThrow()
862
+
863
+ // No connection → no lifecycle evaluation, so no "unavailable" noise.
864
+ expect(unavail).toHaveLength(0)
865
+ })
866
+ })
607
867
  })
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  Network,
3
3
  NetworkId,
4
+ Namespace,
4
5
  WalletMetadata,
5
6
  ConnectionMode,
6
7
  Connector,
@@ -10,9 +11,12 @@ import type {
10
11
  IntegratedBrowserInjectedProvider,
11
12
  TonConnectWalletProvider
12
13
  } from '@meshconnect/uwc-types'
14
+ import { NoCommonNetworkError } from '@meshconnect/uwc-types'
13
15
  import type { SessionManager } from '../managers/session-manager'
14
16
  import type { EventManager } from '../managers/event-manager'
15
17
  import { raceAbort } from '../utils/abort'
18
+ import { instrumentHandoff } from '../observability/instrument-handoff'
19
+ import { attachLifecycle } from '../observability/lifecycle-bridge'
16
20
 
17
21
  export interface ServiceOptions {
18
22
  signal?: AbortSignal
@@ -20,6 +24,8 @@ export interface ServiceOptions {
20
24
 
21
25
  export class ConnectionService {
22
26
  private activeConnector: Connector | null = null
27
+ /** Tears down the post-connect lifecycle subscription; set while connected. */
28
+ private lifecycleTeardown: (() => void) | undefined
23
29
 
24
30
  constructor(
25
31
  private networks: Network[],
@@ -27,7 +33,9 @@ export class ConnectionService {
27
33
  private sessionManager: SessionManager,
28
34
  private connectors: Map<ConnectionMode, Connector>,
29
35
  private usingIntegratedBrowser: boolean,
30
- private eventManager: EventManager
36
+ private eventManager: EventManager,
37
+ // Observational wallet-response deadline for the handoff timeout marker.
38
+ private walletResponseTimeoutMs: number
31
39
  ) {}
32
40
 
33
41
  async connect(
@@ -60,46 +68,67 @@ export class ConnectionService {
60
68
 
61
69
  let result
62
70
  try {
63
- // Race the connector call against the AbortSignal so a mid-flight abort
64
- // (e.g. user cancels the WalletConnect QR while the prompt is open)
65
- // rejects the caller's await promptly. The underlying wallet prompt
66
- // itself can't be cancelled, but the dapp UI is no longer blocked on it.
67
- if (connectionMode === 'injected') {
68
- result = await raceAbort(
69
- connector.connect(
70
- network,
71
- provider as
72
- | ExtensionInjectedProvider
73
- | IntegratedBrowserInjectedProvider
74
- ),
75
- options?.signal
76
- )
77
- } else if (
78
- connectionMode === 'tonConnect' ||
79
- connectionMode === 'walletConnect'
80
- ) {
81
- result = await raceAbort(
82
- this.connectWithURIPoll(
83
- connector,
84
- network,
85
- provider,
86
- connectionMode,
87
- options?.signal
88
- ),
89
- options?.signal
90
- )
91
- } else {
92
- throw new Error(`Unsupported connection mode: ${connectionMode}`)
93
- }
71
+ // Instrument the wallet hand-off HERE after validation + the `connecting`
72
+ // emit so `awaitingWallet` marks the real wallet boundary. A pre-wallet
73
+ // lookup/validation failure throws above and never enters the funnel. The
74
+ // resolved `network` gives accurate namespace/chainId facets.
75
+ result = await instrumentHandoff(
76
+ {
77
+ eventManager: this.eventManager,
78
+ timeoutMs: this.walletResponseTimeoutMs,
79
+ signal: options?.signal
80
+ },
81
+ {
82
+ operation: 'connect',
83
+ connectionMode,
84
+ walletId,
85
+ namespace: network.namespace,
86
+ chainId: network.id
87
+ },
88
+ async () => {
89
+ // Race the connector call against the AbortSignal so a mid-flight abort
90
+ // (e.g. user cancels the WalletConnect QR while the prompt is open)
91
+ // rejects the caller's await promptly. The underlying wallet prompt
92
+ // itself can't be cancelled, but the dapp UI is no longer blocked on it.
93
+ if (connectionMode === 'injected') {
94
+ return await raceAbort(
95
+ connector.connect(
96
+ network,
97
+ provider as
98
+ | ExtensionInjectedProvider
99
+ | IntegratedBrowserInjectedProvider
100
+ ),
101
+ options?.signal
102
+ )
103
+ } else if (
104
+ connectionMode === 'tonConnect' ||
105
+ connectionMode === 'walletConnect'
106
+ ) {
107
+ return await raceAbort(
108
+ this.connectWithURIPoll(
109
+ connector,
110
+ network,
111
+ provider,
112
+ connectionMode,
113
+ options?.signal
114
+ ),
115
+ options?.signal
116
+ )
117
+ } else {
118
+ throw new Error(`Unsupported connection mode: ${connectionMode}`)
119
+ }
120
+ }
121
+ )
122
+ // Belt-and-suspenders: if the signal fired between raceAbort resolving
123
+ // and this point, still bail before mutating session. Inside the try so
124
+ // the half-attached connector is released on this late abort too — a
125
+ // stale activeConnector would keep serving the aborted attempt's URI.
126
+ options?.signal?.throwIfAborted()
94
127
  } catch (error) {
95
128
  this.activeConnector = null
96
129
  throw error
97
130
  }
98
131
 
99
- // Belt-and-suspenders: if the signal fired between raceAbort resolving
100
- // and this point, still bail before mutating session.
101
- options?.signal?.throwIfAborted()
102
-
103
132
  // Filter available addresses to only include those for configured networks
104
133
  const filteredAvailableAddresses = result.availableAddresses.filter(addr =>
105
134
  this.networks.some(n => n.id === addr.networkId)
@@ -120,6 +149,99 @@ export class ConnectionService {
120
149
  const session = this.sessionManager.getSession()
121
150
  this.eventManager.emit('connected', { session })
122
151
  this.eventManager.emit('sessionChanged', { session })
152
+
153
+ // Observe post-connect wallet lifecycle (account/chain switch in the wallet,
154
+ // disconnect, expiry) from the live provider — telemetry only, never changes
155
+ // connection behaviour. Torn down on disconnect.
156
+ this.attachLifecycle(connectionMode, wallet.id, network.namespace)
157
+ }
158
+
159
+ /** Wire the active connector's lifecycle stream onto the bus (or signal absence). */
160
+ private attachLifecycle(
161
+ connectionMode: ConnectionMode,
162
+ walletId: string,
163
+ namespace: Namespace,
164
+ initialSwitch?: { chainId: NetworkId; at: number }
165
+ ): void {
166
+ this.teardownLifecycle()
167
+ const connector = this.activeConnector
168
+ this.lifecycleTeardown = attachLifecycle({
169
+ eventManager: this.eventManager,
170
+ // Reattach-after-switch only: seed echo suppression, because the fresh
171
+ // subscription never saw the completed switch's bus events.
172
+ ...(initialSwitch && { initialSwitch }),
173
+ // The context names the session being attached FOR — the connector routes
174
+ // by it (never by its own ambient state, which is ambiguous when the
175
+ // secondary-namespace gather left several live surfaces).
176
+ subscribe: connector?.onLifecycleEvent
177
+ ? listener =>
178
+ connector.onLifecycleEvent!(listener, {
179
+ connectionMode,
180
+ namespace,
181
+ walletId
182
+ })
183
+ : undefined,
184
+ getRouting: () => {
185
+ const s = this.sessionManager.getSession()
186
+ return {
187
+ connectionMode: s.connectionMode ?? connectionMode,
188
+ walletId: s.activeWallet?.id ?? walletId,
189
+ namespace: s.activeNetwork?.namespace ?? namespace
190
+ }
191
+ },
192
+ connectionMode,
193
+ walletId,
194
+ namespace,
195
+ // The bridge child sets this when UWC runs inside the Link iframe; it tells
196
+ // a `lifecycleTelemetryUnavailable` consumer whether lifecycle was silent on
197
+ // the direct page or (expected, pending P3) over the production bridge.
198
+ runtime:
199
+ (globalThis as { UWCBridgeChildInitialized?: boolean })
200
+ .UWCBridgeChildInitialized === true
201
+ ? 'iframe'
202
+ : 'direct'
203
+ })
204
+ }
205
+
206
+ /**
207
+ * Remove the active provider-lifecycle subscription (EIP-1193 `.on` / wallet-
208
+ * adapter / SignClient listeners) WITHOUT disconnecting the wallet. Idempotent.
209
+ * Exposed so a full `dispose()` that bypasses `disconnect()` still releases these
210
+ * listeners — leaving them attached is the zombie-listener class this layer
211
+ * exists to prevent.
212
+ */
213
+ teardownLifecycle(): void {
214
+ this.lifecycleTeardown?.()
215
+ this.lifecycleTeardown = undefined
216
+ }
217
+
218
+ /**
219
+ * Re-evaluate the lifecycle source for the CURRENT session. Used after a
220
+ * cross-namespace switchNetwork: the live provider changes with the namespace,
221
+ * so the connect-time subscription would keep observing the old namespace's
222
+ * provider and label its events with the new session's routing. Tears the old
223
+ * subscription down and re-attaches against the active session's facets.
224
+ * Safe no-op when nothing is connected.
225
+ */
226
+ reattachLifecycle(): void {
227
+ const session = this.sessionManager.getSession()
228
+ if (
229
+ !this.activeConnector ||
230
+ !session.connectionMode ||
231
+ !session.activeWallet ||
232
+ !session.activeNetwork
233
+ ) {
234
+ return
235
+ }
236
+ this.attachLifecycle(
237
+ session.connectionMode,
238
+ session.activeWallet.id,
239
+ 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() }
244
+ )
123
245
  }
124
246
 
125
247
  private async connectWithURIPoll(
@@ -264,8 +386,12 @@ export class ConnectionService {
264
386
  this.networks.some(n => n.id === id)
265
387
  )
266
388
  if (!supportedNetwork) {
267
- throw new Error(
268
- 'No common supported network found between wallet and configured networks'
389
+ // The #1 production connection failure. Throw a typed, self-describing
390
+ // error carrying both CAIP-2 id lists (non-PII) so telemetry can tell a
391
+ // metadata gap from a client-config mistake — see NoCommonNetworkError.
392
+ throw new NoCommonNetworkError(
393
+ provider.supportedNetworkIds,
394
+ this.networks.map(n => n.id)
269
395
  )
270
396
  }
271
397
  targetNetworkId = supportedNetwork
@@ -283,6 +409,9 @@ export class ConnectionService {
283
409
 
284
410
  async disconnect(options?: ServiceOptions): Promise<void> {
285
411
  options?.signal?.throwIfAborted()
412
+ // Remove the lifecycle listeners first so a provider 'disconnect' fired during
413
+ // teardown doesn't re-enter as a wallet-initiated `walletDisconnected`.
414
+ this.teardownLifecycle()
286
415
  try {
287
416
  if (
288
417
  this.activeConnector &&
@@ -84,7 +84,8 @@ describe('NetworkSwitchService', () => {
84
84
  sessionManager,
85
85
  connectors,
86
86
  false,
87
- eventManager
87
+ eventManager,
88
+ 60_000
88
89
  )
89
90
  })
90
91
 
@@ -184,4 +185,53 @@ describe('NetworkSwitchService', () => {
184
185
  "Wallet 'metamask' does not have a provider for injected mode"
185
186
  )
186
187
  })
188
+
189
+ it('does NOT emit awaitingWallet when validation throws (no active wallet)', async () => {
190
+ const awaiting = vi.fn()
191
+ eventManager.on('awaitingWallet', awaiting)
192
+
193
+ await expect(
194
+ networkSwitchService.switchNetwork('eip155:1')
195
+ ).rejects.toThrow()
196
+
197
+ expect(awaiting).not.toHaveBeenCalled()
198
+ })
199
+
200
+ it('emits awaitingWallet + walletSucceeded around the wallet switch call', async () => {
201
+ sessionManager.updateSession({
202
+ connectionMode: 'injected',
203
+ activeWallet: {
204
+ id: 'metamask',
205
+ name: 'MetaMask',
206
+ metadata: {
207
+ icon: 'test',
208
+ downloadUrl: 'test',
209
+ supportedChains: ['eip155:1', 'eip155:137']
210
+ },
211
+ extensionInjectedProvider: {
212
+ supportedNetworkIds: ['eip155:1', 'eip155:137']
213
+ }
214
+ } as any
215
+ })
216
+ mockConnector.switchNetwork = vi.fn().mockResolvedValue({
217
+ address: '0xabc',
218
+ availableAddresses: [{ address: '0xabc', networkId: 'eip155:137' }]
219
+ })
220
+ const events: Array<{ name: string; data: any }> = []
221
+ eventManager.on('awaitingWallet', d =>
222
+ events.push({ name: 'awaitingWallet', data: d })
223
+ )
224
+ eventManager.on('walletSucceeded', d =>
225
+ events.push({ name: 'walletSucceeded', data: d })
226
+ )
227
+
228
+ await networkSwitchService.switchNetwork('eip155:137')
229
+
230
+ expect(events.map(e => e.name)).toEqual([
231
+ 'awaitingWallet',
232
+ 'walletSucceeded'
233
+ ])
234
+ expect(events[0].data.operation).toBe('switchNetwork')
235
+ expect(events[0].data.chainId).toBe('eip155:137')
236
+ })
187
237
  })