@meshconnect/uwc-core 2.7.2 → 2.7.3-snapshot.0728ff3

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 (46) hide show
  1. package/dist/events.d.ts +25 -1
  2. package/dist/events.d.ts.map +1 -1
  3. package/dist/events.js.map +1 -1
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/observability/instrument-handoff.d.ts +5 -0
  9. package/dist/observability/instrument-handoff.d.ts.map +1 -1
  10. package/dist/observability/instrument-handoff.js +12 -0
  11. package/dist/observability/instrument-handoff.js.map +1 -1
  12. package/dist/observability/telemetry.d.ts +16 -1
  13. package/dist/observability/telemetry.d.ts.map +1 -1
  14. package/dist/observability/telemetry.js +15 -2
  15. package/dist/observability/telemetry.js.map +1 -1
  16. package/dist/services/connection-service.d.ts +11 -2
  17. package/dist/services/connection-service.d.ts.map +1 -1
  18. package/dist/services/connection-service.js +34 -17
  19. package/dist/services/connection-service.js.map +1 -1
  20. package/dist/services/index.d.ts +1 -0
  21. package/dist/services/index.d.ts.map +1 -1
  22. package/dist/services/index.js +1 -0
  23. package/dist/services/index.js.map +1 -1
  24. package/dist/services/network-selection.d.ts +34 -0
  25. package/dist/services/network-selection.d.ts.map +1 -0
  26. package/dist/services/network-selection.js +54 -0
  27. package/dist/services/network-selection.js.map +1 -0
  28. package/dist/universal-wallet-connector.d.ts +16 -1
  29. package/dist/universal-wallet-connector.d.ts.map +1 -1
  30. package/dist/universal-wallet-connector.js +2 -1
  31. package/dist/universal-wallet-connector.js.map +1 -1
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/package.json +5 -5
  35. package/src/events.ts +26 -1
  36. package/src/index.ts +4 -0
  37. package/src/observability/instrument-handoff.ts +17 -0
  38. package/src/observability/telemetry.test.ts +37 -0
  39. package/src/observability/telemetry.ts +33 -3
  40. package/src/services/connection-service.test.ts +107 -6
  41. package/src/services/connection-service.ts +51 -21
  42. package/src/services/index.ts +1 -0
  43. package/src/services/network-selection.test.ts +152 -0
  44. package/src/services/network-selection.ts +86 -0
  45. package/src/universal-wallet-connector.ts +18 -1
  46. package/src/version.ts +1 -1
@@ -11,7 +11,8 @@ import type {
11
11
  LifecycleUnavailableReason,
12
12
  Session,
13
13
  WalletStepName,
14
- WalletStepOutcome
14
+ WalletStepOutcome,
15
+ ErrorType
15
16
  } from '@meshconnect/uwc-types'
16
17
  import type { UWCEventName, UWCEventMap, UWCOperation } from '../events'
17
18
  import { UWC_CORE_VERSION } from '../version'
@@ -123,6 +124,10 @@ export interface UWCTelemetryEvent {
123
124
  targetAlreadyActive?: boolean | undefined
124
125
  /** connect only: a `silentOnly` attempt — tells a silent restore from a tapped connect. */
125
126
  silentOnly?: boolean | undefined
127
+ /** connect only: how the connect network was chosen — explicit, detected, or catalog order. */
128
+ networkSelection?: 'explicit' | 'detected' | 'catalogOrder' | undefined
129
+ /** connect only: secondary namespaces gathered without first-time prompts. */
130
+ quietSecondaries?: boolean | undefined
126
131
  /** Non-success outcomes: stringified error code when reliably known. */
127
132
  errorCode?: string | undefined
128
133
  /** Non-success outcomes: coarse cause bucket. `unknown` when not classifiable. */
@@ -218,6 +223,17 @@ export interface UWCTelemetryEvent {
218
223
  * still succeeded), so conflating the two would misreport success as failure.
219
224
  */
220
225
  stepOutcome?: WalletStepOutcome | undefined
226
+ /**
227
+ * `walletStep` only: the `name` of what a `failed` step threw (e.g.
228
+ * `WalletAccountError`), so a swallowed failure can be told apart in
229
+ * Datadog. Never the message.
230
+ */
231
+ stepErrorName?: string | undefined
232
+ /**
233
+ * `walletStep` only: `rejected` when a `failed` step was the user declining,
234
+ * so a declined secondary prompt can be counted apart from a wallet fault.
235
+ */
236
+ stepErrorType?: ErrorType | undefined
221
237
  /** `pageVisibleDuringHandoff` only: how long the page was hidden for that segment (ms). */
222
238
  hiddenMs?: number | undefined
223
239
  /** Handoff terminals: cumulative time the page was backgrounded during the handoff (ms). */
@@ -473,7 +489,9 @@ function buildTelemetryEvent<K extends UWCEventName>(
473
489
  walletFlowType: d.walletFlowType,
474
490
  platform: d.platform,
475
491
  targetAlreadyActive: d.targetAlreadyActive,
476
- silentOnly: d.silentOnly
492
+ silentOnly: d.silentOnly,
493
+ networkSelection: d.networkSelection,
494
+ quietSecondaries: d.quietSecondaries
477
495
  }
478
496
  }
479
497
  case 'walletSucceeded': {
@@ -492,6 +510,8 @@ function buildTelemetryEvent<K extends UWCEventName>(
492
510
  timeoutMs: d.timeoutMs,
493
511
  targetAlreadyActive: d.targetAlreadyActive,
494
512
  silentOnly: d.silentOnly,
513
+ networkSelection: d.networkSelection,
514
+ quietSecondaries: d.quietSecondaries,
495
515
  backgroundedMs: d.backgroundedMs,
496
516
  backgroundCount: d.backgroundCount,
497
517
  relayDropCount: d.relayDropCount,
@@ -514,6 +534,8 @@ function buildTelemetryEvent<K extends UWCEventName>(
514
534
  timeoutMs: d.timeoutMs,
515
535
  targetAlreadyActive: d.targetAlreadyActive,
516
536
  silentOnly: d.silentOnly,
537
+ networkSelection: d.networkSelection,
538
+ quietSecondaries: d.quietSecondaries,
517
539
  errorCode: d.errorCode,
518
540
  failureKind: d.failureKind,
519
541
  source: d.source,
@@ -538,6 +560,8 @@ function buildTelemetryEvent<K extends UWCEventName>(
538
560
  timeoutMs: d.timeoutMs,
539
561
  targetAlreadyActive: d.targetAlreadyActive,
540
562
  silentOnly: d.silentOnly,
563
+ networkSelection: d.networkSelection,
564
+ quietSecondaries: d.quietSecondaries,
541
565
  backgroundedMs: d.backgroundedMs,
542
566
  backgroundCount: d.backgroundCount,
543
567
  relayDropCount: d.relayDropCount
@@ -555,7 +579,11 @@ function buildTelemetryEvent<K extends UWCEventName>(
555
579
  durationMs: d.durationMs,
556
580
  step: d.step,
557
581
  stepOutcome: d.outcome,
558
- timeoutMs: d.timeoutMs
582
+ timeoutMs: d.timeoutMs,
583
+ // A wallet sets the name it throws, so it is scrubbed like a message.
584
+ stepErrorName:
585
+ d.errorName === undefined ? undefined : scrubMessage(d.errorName),
586
+ stepErrorType: d.errorType
559
587
  }
560
588
  }
561
589
  case 'walletFailed': {
@@ -574,6 +602,8 @@ function buildTelemetryEvent<K extends UWCEventName>(
574
602
  timeoutMs: d.timeoutMs,
575
603
  targetAlreadyActive: d.targetAlreadyActive,
576
604
  silentOnly: d.silentOnly,
605
+ networkSelection: d.networkSelection,
606
+ quietSecondaries: d.quietSecondaries,
577
607
  errorCode: d.errorCode,
578
608
  failureKind: d.failureKind,
579
609
  source: d.source,
@@ -114,11 +114,11 @@ describe('ConnectionService', () => {
114
114
  const mockProvider = {
115
115
  supportedNetworkIds: ['eip155:1']
116
116
  }
117
- const network = connectionService['validateAndDetermineNetwork'](
118
- 'eip155:1',
119
- mockProvider as any
120
- )
117
+ const { network, selection } = connectionService[
118
+ 'validateAndDetermineNetwork'
119
+ ]('eip155:1', mockProvider as any, 'injected')
121
120
  expect(network).toEqual(networks[0])
121
+ expect(selection).toBe('explicit')
122
122
  })
123
123
 
124
124
  it('should throw error for unknown network', () => {
@@ -128,7 +128,8 @@ describe('ConnectionService', () => {
128
128
  expect(() => {
129
129
  connectionService['validateAndDetermineNetwork'](
130
130
  'eip155:8453',
131
- mockProvider as any
131
+ mockProvider as any,
132
+ 'injected'
132
133
  )
133
134
  }).toThrow('Network eip155:8453 is not in the configured networks')
134
135
  })
@@ -143,7 +144,8 @@ describe('ConnectionService', () => {
143
144
  try {
144
145
  connectionService['validateAndDetermineNetwork'](
145
146
  undefined,
146
- mockProvider as any
147
+ mockProvider as any,
148
+ 'injected'
147
149
  )
148
150
  } catch (e) {
149
151
  thrown = e
@@ -265,6 +267,67 @@ describe('ConnectionService', () => {
265
267
  ])
266
268
  })
267
269
 
270
+ it('forwards quietSecondaries to the injected connector and tags the funnel with it', async () => {
271
+ const provider = { supportedNetworkIds: ['eip155:1'] }
272
+ wallets[0].extensionInjectedProvider = provider as any
273
+ mockConnector.connect = vi.fn().mockResolvedValue({
274
+ networkId: 'eip155:1',
275
+ address: '0x1234',
276
+ availableAddresses: [{ address: '0x1234', networkId: 'eip155:1' }]
277
+ })
278
+ const awaiting = vi.fn()
279
+ const succeeded = vi.fn()
280
+ eventManager.on('awaitingWallet', awaiting)
281
+ eventManager.on('walletSucceeded', succeeded)
282
+
283
+ await connectionService.connect('injected', 'metamask', 'eip155:1', {
284
+ quietSecondaries: true
285
+ })
286
+ await connectionService.connect('injected', 'metamask', 'eip155:1')
287
+
288
+ expect(mockConnector.connect).toHaveBeenNthCalledWith(
289
+ 1,
290
+ networks[0],
291
+ provider,
292
+ { quietSecondaries: true }
293
+ )
294
+ expect(mockConnector.connect).toHaveBeenNthCalledWith(
295
+ 2,
296
+ networks[0],
297
+ provider,
298
+ undefined
299
+ )
300
+ expect(awaiting.mock.calls.map(([e]) => e.quietSecondaries)).toEqual([
301
+ true,
302
+ false
303
+ ])
304
+ expect(succeeded.mock.calls.map(([e]) => e.quietSecondaries)).toEqual([
305
+ true,
306
+ false
307
+ ])
308
+ })
309
+
310
+ it('combines quietSecondaries with requiredAddresses', async () => {
311
+ const provider = { supportedNetworkIds: ['eip155:1'] }
312
+ wallets[0].extensionInjectedProvider = provider as any
313
+ mockConnector.connect = vi.fn().mockResolvedValue({
314
+ networkId: 'eip155:1',
315
+ address: '0x1234',
316
+ availableAddresses: [{ address: '0x1234', networkId: 'eip155:1' }]
317
+ })
318
+
319
+ await connectionService.connect('injected', 'metamask', 'eip155:1', {
320
+ requiredAddresses: ['0x1234'],
321
+ quietSecondaries: true
322
+ })
323
+
324
+ expect(mockConnector.connect).toHaveBeenCalledWith(
325
+ networks[0],
326
+ provider,
327
+ { requiredAddresses: ['0x1234'], quietSecondaries: true }
328
+ )
329
+ })
330
+
268
331
  it('passes no connect options when neither silentOnly nor requiredAddresses is set', async () => {
269
332
  const provider = { supportedNetworkIds: ['eip155:1'] }
270
333
  wallets[0].extensionInjectedProvider = provider as any
@@ -376,6 +439,44 @@ describe('ConnectionService', () => {
376
439
  expect(awaiting.namespace).toBe('eip155')
377
440
  expect(awaiting.chainId).toBe('eip155:1')
378
441
  expect(awaiting.handoffId).toBe(events[1].data.handoffId)
442
+ expect(awaiting.networkSelection).toBe('explicit')
443
+ })
444
+
445
+ it('auto-picks a detected namespace over an undetected first network', async () => {
446
+ networks.push({
447
+ ...networks[0],
448
+ id: 'tron:0x2b6653dc',
449
+ internalId: 2,
450
+ name: 'Tron',
451
+ namespace: 'tron',
452
+ networkType: 'tron'
453
+ } as Network)
454
+ // Trust mobile: catalog lists Tron first, but only EVM was detected.
455
+ wallets[0].extensionInjectedProvider = {
456
+ supportedNetworkIds: ['tron:0x2b6653dc', 'eip155:1'],
457
+ namespaceMetaData: {
458
+ tron: { injectedId: 'trustWallet.tronLink', installed: false },
459
+ eip155: { eip155Name: 'Trust Wallet', installed: true }
460
+ }
461
+ } as any
462
+ const awaiting = vi.fn()
463
+ eventManager.on('awaitingWallet', awaiting)
464
+ mockConnector.connect = vi.fn().mockResolvedValue({
465
+ networkId: 'eip155:1',
466
+ address: '0x1234',
467
+ availableAddresses: [{ address: '0x1234', networkId: 'eip155:1' }]
468
+ })
469
+
470
+ await connectionService.connect('injected', 'metamask')
471
+
472
+ expect(awaiting).toHaveBeenCalledWith(
473
+ expect.objectContaining({
474
+ chainId: 'eip155:1',
475
+ namespace: 'eip155',
476
+ networkSelection: 'detected'
477
+ })
478
+ )
479
+ expect(sessionManager.getSession().activeNetwork?.id).toBe('eip155:1')
379
480
  })
380
481
 
381
482
  it('emits one stable attempt ID per connect and a different ID for the next connect', async () => {
@@ -5,6 +5,7 @@ import type {
5
5
  WalletMetadata,
6
6
  ConnectionMode,
7
7
  Connector,
8
+ ConnectorConnectOptions,
8
9
  ConnectorResult,
9
10
  SupportedProvider,
10
11
  ExtensionInjectedProvider,
@@ -18,6 +19,11 @@ import { raceAbort } from '../utils/abort'
18
19
  import { generateId } from '../utils/id'
19
20
  import { instrumentHandoff } from '../observability/instrument-handoff'
20
21
  import { attachLifecycle } from '../observability/lifecycle-bridge'
22
+ import {
23
+ DEFAULT_NAMESPACE_PRIORITY,
24
+ type NetworkSelection,
25
+ pickAutoNetwork
26
+ } from './network-selection'
21
27
 
22
28
  export interface ServiceOptions {
23
29
  signal?: AbortSignal
@@ -31,6 +37,24 @@ export interface ServiceOptions {
31
37
  * Injected mode only; see `ConnectorConnectOptions.silentOnly`.
32
38
  */
33
39
  silentOnly?: boolean
40
+ /**
41
+ * No first-time prompt for a secondary namespace (connect only). Injected
42
+ * mode only; see `ConnectorConnectOptions.quietSecondaries`.
43
+ */
44
+ quietSecondaries?: boolean
45
+ }
46
+
47
+ /** The connector options a connect carries, or undefined when it sets none. */
48
+ function injectedConnectOptions(
49
+ options: ServiceOptions | undefined
50
+ ): ConnectorConnectOptions | undefined {
51
+ const { requiredAddresses, silentOnly, quietSecondaries } = options ?? {}
52
+ if (!requiredAddresses && !silentOnly && !quietSecondaries) return undefined
53
+ return {
54
+ ...(requiredAddresses ? { requiredAddresses } : {}),
55
+ ...(silentOnly ? { silentOnly } : {}),
56
+ ...(quietSecondaries ? { quietSecondaries } : {})
57
+ }
34
58
  }
35
59
 
36
60
  /** Sibling-namespace live-account re-read poll: absorbs the lag while a wallet
@@ -87,7 +111,9 @@ export class ConnectionService {
87
111
  private usingIntegratedBrowser: boolean,
88
112
  private eventManager: EventManager,
89
113
  // Observational wallet-response deadline for the handoff timeout marker.
90
- private walletResponseTimeoutMs: number
114
+ private walletResponseTimeoutMs: number,
115
+ /** Namespace preference for an auto-picked connect network — see `pickAutoNetwork`. */
116
+ private preferredNamespaces: readonly Namespace[] = DEFAULT_NAMESPACE_PRIORITY
91
117
  ) {}
92
118
 
93
119
  async connect(
@@ -104,7 +130,8 @@ export class ConnectionService {
104
130
  // connect() would spuriously abort (MFS-602).
105
131
  const wallet = this.findWallet(walletId)
106
132
  const provider = this.getProviderForMode(wallet, connectionMode)
107
- const network = this.validateAndDetermineNetwork(networkId, provider)
133
+ const { network, selection: networkSelection } =
134
+ this.validateAndDetermineNetwork(networkId, provider, connectionMode)
108
135
  const connector = this.getConnector(connectionMode)
109
136
  // A silent-only connect is a promise never to open wallet UI. Only the
110
137
  // injected connector can keep it — WalletConnect / TON Connect always hand
@@ -176,7 +203,9 @@ export class ConnectionService {
176
203
  connectAttemptId,
177
204
  // Lets telemetry tell a silent restore from a tapped connect: both
178
205
  // succeed the same way, and only a miss carries a distinct failureKind.
179
- silentOnly: options?.silentOnly === true
206
+ silentOnly: options?.silentOnly === true,
207
+ networkSelection,
208
+ quietSecondaries: options?.quietSecondaries === true
180
209
  },
181
210
  async handoffId => {
182
211
  // Publish this attempt's routing for the connector's step sink.
@@ -189,8 +218,7 @@ export class ConnectionService {
189
218
  provider,
190
219
  signal,
191
220
  connectAttemptId,
192
- options?.requiredAddresses,
193
- options?.silentOnly
221
+ options
194
222
  )
195
223
  } finally {
196
224
  // Retract ONLY if we are still the published attempt. An
@@ -281,8 +309,7 @@ export class ConnectionService {
281
309
  provider: SupportedProvider,
282
310
  signal: AbortSignal,
283
311
  connectAttemptId: string,
284
- requiredAddresses?: readonly string[],
285
- silentOnly?: boolean
312
+ options?: ServiceOptions
286
313
  ): Promise<ConnectorResult> {
287
314
  // Race the connector call against the AbortSignal so a mid-flight abort
288
315
  // (e.g. user cancels the WalletConnect QR while the prompt is open)
@@ -295,12 +322,7 @@ export class ConnectionService {
295
322
  provider as
296
323
  | ExtensionInjectedProvider
297
324
  | IntegratedBrowserInjectedProvider,
298
- requiredAddresses || silentOnly
299
- ? {
300
- ...(requiredAddresses ? { requiredAddresses } : {}),
301
- ...(silentOnly ? { silentOnly } : {})
302
- }
303
- : undefined
325
+ injectedConnectOptions(options)
304
326
  ),
305
327
  signal
306
328
  )
@@ -947,9 +969,11 @@ export class ConnectionService {
947
969
 
948
970
  private validateAndDetermineNetwork(
949
971
  networkId: NetworkId | undefined,
950
- provider: SupportedProvider
951
- ): Network {
972
+ provider: SupportedProvider,
973
+ connectionMode: ConnectionMode
974
+ ): { network: Network; selection: NetworkSelection } {
952
975
  let targetNetworkId: NetworkId
976
+ let selection: NetworkSelection = 'explicit'
953
977
 
954
978
  if (networkId) {
955
979
  // Check if networkId is in the configured networks
@@ -968,11 +992,16 @@ export class ConnectionService {
968
992
 
969
993
  targetNetworkId = networkId
970
994
  } else {
971
- // Use the first supported network that exists in both wallet and configured networks
972
- const supportedNetwork = provider.supportedNetworkIds.find(id =>
973
- this.networks.some(n => n.id === id)
995
+ // No network named: prefer a namespace detection found installed (injected
996
+ // only), ranked by `preferredNamespaces`; otherwise the first supported
997
+ // network that is also configured (catalog order).
998
+ const picked = pickAutoNetwork(
999
+ provider,
1000
+ this.networks,
1001
+ connectionMode,
1002
+ this.preferredNamespaces
974
1003
  )
975
- if (!supportedNetwork) {
1004
+ if (!picked) {
976
1005
  // The #1 production connection failure. Throw a typed, self-describing
977
1006
  // error carrying both CAIP-2 id lists (non-PII) so telemetry can tell a
978
1007
  // metadata gap from a client-config mistake — see NoCommonNetworkError.
@@ -981,7 +1010,8 @@ export class ConnectionService {
981
1010
  this.networks.map(n => n.id)
982
1011
  )
983
1012
  }
984
- targetNetworkId = supportedNetwork
1013
+ targetNetworkId = picked.networkId
1014
+ selection = picked.selection
985
1015
  }
986
1016
 
987
1017
  // Find and return the network object
@@ -991,7 +1021,7 @@ export class ConnectionService {
991
1021
  `Network ${targetNetworkId} not found in configured networks`
992
1022
  )
993
1023
  }
994
- return network
1024
+ return { network, selection }
995
1025
  }
996
1026
 
997
1027
  async disconnect(options?: ServiceOptions): Promise<void> {
@@ -1,3 +1,4 @@
1
1
  export * from './connection-service'
2
+ export * from './network-selection'
2
3
  export * from './signature-service'
3
4
  export * from './transaction-service'
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Network, SupportedProvider } from '@meshconnect/uwc-types'
3
+ import {
4
+ DEFAULT_NAMESPACE_PRIORITY,
5
+ pickAutoNetwork
6
+ } from './network-selection'
7
+
8
+ const network = (id: string, namespace: Network['namespace']): Network =>
9
+ ({
10
+ id,
11
+ internalId: 0,
12
+ name: id,
13
+ namespace,
14
+ logoUrl: '',
15
+ networkType: 'evm',
16
+ nativeCurrency: { name: '', symbol: '', decimals: 18 }
17
+ }) as Network
18
+
19
+ const NETWORKS = [
20
+ network('eip155:1', 'eip155'),
21
+ network('eip155:81457', 'eip155'),
22
+ network('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 'solana'),
23
+ network('tron:0x2b6653dc', 'tron'),
24
+ network('tvm:-239', 'tvm')
25
+ ]
26
+
27
+ /** An injected provider whose namespaces carry detection's `installed` flag. */
28
+ const injected = (
29
+ supportedNetworkIds: string[],
30
+ installed: Partial<Record<'eip155' | 'solana' | 'tron' | 'tvm', boolean>>
31
+ ): SupportedProvider =>
32
+ ({
33
+ supportedNetworkIds,
34
+ namespaceMetaData: {
35
+ eip155: { eip155Name: 'W', installed: installed.eip155 },
36
+ solana: { walletStandardName: 'W', installed: installed.solana },
37
+ tron: { injectedId: 'w.tron', installed: installed.tron },
38
+ tvm: { jsBridgeKey: 'w', installed: installed.tvm }
39
+ }
40
+ }) as unknown as SupportedProvider
41
+
42
+ describe('pickAutoNetwork', () => {
43
+ it('skips an undetected first network (Trust mobile: Tron first, not injected)', () => {
44
+ const provider = injected(
45
+ [
46
+ 'tron:0x2b6653dc',
47
+ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
48
+ 'eip155:1'
49
+ ],
50
+ { eip155: true, solana: true, tron: false, tvm: true }
51
+ )
52
+ expect(
53
+ pickAutoNetwork(
54
+ provider,
55
+ NETWORKS,
56
+ 'injected',
57
+ DEFAULT_NAMESPACE_PRIORITY
58
+ )
59
+ ).toEqual({ networkId: 'eip155:1', selection: 'detected' })
60
+ })
61
+
62
+ it('keeps catalog order within a namespace', () => {
63
+ const provider = injected(['eip155:81457', 'eip155:1'], { eip155: true })
64
+ expect(
65
+ pickAutoNetwork(
66
+ provider,
67
+ NETWORKS,
68
+ 'injected',
69
+ DEFAULT_NAMESPACE_PRIORITY
70
+ )?.networkId
71
+ ).toBe('eip155:81457')
72
+ })
73
+
74
+ it('honours a caller-supplied namespace preference', () => {
75
+ const provider = injected(
76
+ ['eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'],
77
+ { eip155: true, solana: true }
78
+ )
79
+ expect(
80
+ pickAutoNetwork(provider, NETWORKS, 'injected', ['solana', 'eip155'])
81
+ ).toEqual({
82
+ networkId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
83
+ selection: 'detected'
84
+ })
85
+ })
86
+
87
+ it('ranks a namespace missing from the preference list last', () => {
88
+ const provider = injected(['tvm:-239', 'tron:0x2b6653dc'], {
89
+ tvm: true,
90
+ tron: true
91
+ })
92
+ expect(
93
+ pickAutoNetwork(provider, NETWORKS, 'injected', ['tron'])?.networkId
94
+ ).toBe('tron:0x2b6653dc')
95
+ })
96
+
97
+ it('falls back to catalog order when nothing was detected (e.g. before detection)', () => {
98
+ const provider = injected(['tron:0x2b6653dc', 'eip155:1'], {})
99
+ expect(
100
+ pickAutoNetwork(
101
+ provider,
102
+ NETWORKS,
103
+ 'injected',
104
+ DEFAULT_NAMESPACE_PRIORITY
105
+ )
106
+ ).toEqual({ networkId: 'tron:0x2b6653dc', selection: 'catalogOrder' })
107
+ })
108
+
109
+ it('leaves non-injected modes on catalog order even with installed flags', () => {
110
+ const provider = injected(['tron:0x2b6653dc', 'eip155:1'], {
111
+ eip155: true
112
+ })
113
+ expect(
114
+ pickAutoNetwork(
115
+ provider,
116
+ NETWORKS,
117
+ 'walletConnect',
118
+ DEFAULT_NAMESPACE_PRIORITY
119
+ )
120
+ ).toEqual({ networkId: 'tron:0x2b6653dc', selection: 'catalogOrder' })
121
+ })
122
+
123
+ it('ignores supported networks that are not configured', () => {
124
+ const provider = injected(
125
+ ['eip155:999', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'],
126
+ {
127
+ eip155: true,
128
+ solana: true
129
+ }
130
+ )
131
+ expect(
132
+ pickAutoNetwork(
133
+ provider,
134
+ NETWORKS,
135
+ 'injected',
136
+ DEFAULT_NAMESPACE_PRIORITY
137
+ )?.networkId
138
+ ).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp')
139
+ })
140
+
141
+ it('returns undefined when no supported network is configured', () => {
142
+ const provider = injected(['eip155:999'], { eip155: true })
143
+ expect(
144
+ pickAutoNetwork(
145
+ provider,
146
+ NETWORKS,
147
+ 'injected',
148
+ DEFAULT_NAMESPACE_PRIORITY
149
+ )
150
+ ).toBeUndefined()
151
+ })
152
+ })
@@ -0,0 +1,86 @@
1
+ import type {
2
+ ConnectionMode,
3
+ Namespace,
4
+ Network,
5
+ NetworkId,
6
+ SupportedProvider
7
+ } from '@meshconnect/uwc-types'
8
+
9
+ /**
10
+ * Default namespace preference for an auto-picked connect network (no
11
+ * `networkId` passed): EVM first, then Solana, Tron, TON, Bitcoin. A caller
12
+ * policy, not a correctness rule — override it with `UWCConfig.preferredNamespaces`.
13
+ * Same order as the namespace unions used across the connectors.
14
+ */
15
+ export const DEFAULT_NAMESPACE_PRIORITY: readonly Namespace[] = [
16
+ 'eip155',
17
+ 'solana',
18
+ 'tron',
19
+ 'tvm',
20
+ 'bip122'
21
+ ]
22
+
23
+ /**
24
+ * How the connect network was chosen, surfaced on the hand-off telemetry:
25
+ * - `explicit` — the caller passed a `networkId`.
26
+ * - `detected` — auto-picked among namespaces whose injected provider was
27
+ * detected, ranked by the namespace preference, catalog order breaking ties.
28
+ * - `catalogOrder` — legacy pick: the first configured network in the wallet's
29
+ * `supportedNetworkIds` (non-injected modes, or nothing detected yet).
30
+ */
31
+ export type NetworkSelection = 'explicit' | 'detected' | 'catalogOrder'
32
+
33
+ function isNamespaceInstalled(
34
+ provider: SupportedProvider,
35
+ namespace: Namespace
36
+ ): boolean {
37
+ if (!('namespaceMetaData' in provider)) return false
38
+ const meta = provider.namespaceMetaData[
39
+ namespace as keyof typeof provider.namespaceMetaData
40
+ ] as { installed?: boolean } | undefined
41
+ return meta?.installed === true
42
+ }
43
+
44
+ /**
45
+ * Picks the network for a connect that named none. Candidates are the wallet's
46
+ * `supportedNetworkIds` that are also configured, in catalog order.
47
+ *
48
+ * For an injected connect where detection marked at least one candidate's
49
+ * namespace installed, only installed namespaces are eligible (connecting on an
50
+ * undetected namespace can only fail — e.g. Trust mobile has no
51
+ * `trustWallet.tronLink`), ranked by `preferredNamespaces`, catalog order
52
+ * breaking ties. Every other case keeps the legacy first-candidate pick, so
53
+ * WalletConnect / TON Connect and pre-detection connects are unchanged.
54
+ * Returns `undefined` when no candidate exists (the caller throws).
55
+ */
56
+ export function pickAutoNetwork(
57
+ provider: SupportedProvider,
58
+ networks: readonly Network[],
59
+ connectionMode: ConnectionMode,
60
+ preferredNamespaces: readonly Namespace[]
61
+ ): { networkId: NetworkId; selection: NetworkSelection } | undefined {
62
+ const candidates = provider.supportedNetworkIds
63
+ .map(id => networks.find(n => n.id === id))
64
+ .filter((n): n is Network => n !== undefined)
65
+ const first = candidates[0]
66
+ if (!first) return undefined
67
+
68
+ if (connectionMode === 'injected') {
69
+ const installed = candidates.filter(n =>
70
+ isNamespaceInstalled(provider, n.namespace)
71
+ )
72
+ if (installed.length > 0) {
73
+ const rank = (namespace: Namespace) => {
74
+ const index = preferredNamespaces.indexOf(namespace)
75
+ return index === -1 ? preferredNamespaces.length : index
76
+ }
77
+ // Array.prototype.sort is stable, so equal ranks keep catalog order.
78
+ const [best] = [...installed].sort(
79
+ (a, b) => rank(a.namespace) - rank(b.namespace)
80
+ )
81
+ if (best) return { networkId: best.id, selection: 'detected' }
82
+ }
83
+ }
84
+
85
+ return { networkId: first.id, selection: 'catalogOrder' }
86
+ }