@meshconnect/uwc-core 2.6.0 → 2.7.0-snapshot.c392d3d

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 (53) hide show
  1. package/README.md +11 -2
  2. package/dist/events.d.ts +10 -0
  3. package/dist/events.d.ts.map +1 -1
  4. package/dist/events.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/observability/instrument-handoff.d.ts +3 -0
  10. package/dist/observability/instrument-handoff.d.ts.map +1 -1
  11. package/dist/observability/instrument-handoff.js +6 -0
  12. package/dist/observability/instrument-handoff.js.map +1 -1
  13. package/dist/observability/telemetry.d.ts +2 -0
  14. package/dist/observability/telemetry.d.ts.map +1 -1
  15. package/dist/observability/telemetry.js +26 -10
  16. package/dist/observability/telemetry.js.map +1 -1
  17. package/dist/services/connection-service.d.ts +6 -2
  18. package/dist/services/connection-service.d.ts.map +1 -1
  19. package/dist/services/connection-service.js +19 -9
  20. package/dist/services/connection-service.js.map +1 -1
  21. package/dist/services/index.d.ts +1 -0
  22. package/dist/services/index.d.ts.map +1 -1
  23. package/dist/services/index.js +1 -0
  24. package/dist/services/index.js.map +1 -1
  25. package/dist/services/network-selection.d.ts +34 -0
  26. package/dist/services/network-selection.d.ts.map +1 -0
  27. package/dist/services/network-selection.js +54 -0
  28. package/dist/services/network-selection.js.map +1 -0
  29. package/dist/universal-wallet-connector.d.ts +8 -1
  30. package/dist/universal-wallet-connector.d.ts.map +1 -1
  31. package/dist/universal-wallet-connector.js +2 -1
  32. package/dist/universal-wallet-connector.js.map +1 -1
  33. package/dist/utils/to-wallet-error.d.ts.map +1 -1
  34. package/dist/utils/to-wallet-error.js +7 -1
  35. package/dist/utils/to-wallet-error.js.map +1 -1
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +5 -5
  39. package/src/events.ts +10 -0
  40. package/src/index.ts +4 -0
  41. package/src/observability/instrument-handoff.ts +9 -0
  42. package/src/observability/telemetry.test.ts +83 -0
  43. package/src/observability/telemetry.ts +29 -10
  44. package/src/services/connection-service.test.ts +46 -6
  45. package/src/services/connection-service.ts +28 -11
  46. package/src/services/index.ts +1 -0
  47. package/src/services/network-selection.test.ts +152 -0
  48. package/src/services/network-selection.ts +86 -0
  49. package/src/services/transaction-service.test.ts +25 -0
  50. package/src/universal-wallet-connector.ts +10 -1
  51. package/src/utils/to-wallet-error.test.ts +18 -0
  52. package/src/utils/to-wallet-error.ts +7 -1
  53. package/src/version.ts +1 -1
@@ -867,6 +867,89 @@ describe('normalizeEvent', () => {
867
867
  expect(out.connectAttemptId).toBe('attempt-1')
868
868
  })
869
869
 
870
+ it('forwards a scrubbed providerReason on walletFailed', () => {
871
+ const out = normalizeEvent(
872
+ 'walletFailed',
873
+ {
874
+ handoffId: 'h-1',
875
+ operation: 'sendTransaction',
876
+ durationMs: 1,
877
+ error: {
878
+ type: 'unknown',
879
+ message: 'An unknown RPC error occurred.',
880
+ providerReason:
881
+ 'cancelled for 0x1234567890123456789012345678901234567890'
882
+ },
883
+ connectionMode: 'injected',
884
+ timedOut: false
885
+ },
886
+ SDK_SESSION,
887
+ NOW
888
+ )
889
+ expect(out.error?.providerReason).toContain('cancelled for ')
890
+ expect(out.error?.providerReason).not.toContain(
891
+ '1234567890123456789012345678901234567890'
892
+ )
893
+ })
894
+
895
+ it('redacts an address in a long providerReason before capping it', () => {
896
+ const address = '0x1234567890123456789012345678901234567890'
897
+ const out = normalizeEvent(
898
+ 'walletFailed',
899
+ {
900
+ handoffId: 'h-1',
901
+ operation: 'sendTransaction',
902
+ durationMs: 1,
903
+ error: {
904
+ type: 'unknown',
905
+ message: 'An unknown RPC error occurred.',
906
+ // The address straddles the 500-character cap.
907
+ providerReason: `${'x'.repeat(480)} ${address}`
908
+ },
909
+ connectionMode: 'injected',
910
+ timedOut: false
911
+ },
912
+ SDK_SESSION,
913
+ NOW
914
+ )
915
+ expect(out.error?.providerReason).not.toContain('0x1234567890')
916
+ expect(out.error?.providerReason).toContain('0x[redacted-hex]')
917
+ })
918
+
919
+ it('omits providerReason from the record when the error has none', () => {
920
+ const out = normalizeEvent(
921
+ 'walletFailed',
922
+ {
923
+ handoffId: 'h-1',
924
+ operation: 'sendTransaction',
925
+ durationMs: 1,
926
+ error: { type: 'unknown', message: 'failed' },
927
+ connectionMode: 'injected',
928
+ timedOut: false
929
+ },
930
+ SDK_SESSION,
931
+ NOW
932
+ )
933
+ expect(out.error).toEqual({ type: 'unknown', message: 'failed' })
934
+ })
935
+
936
+ it('forwards providerReason on the error event too', () => {
937
+ const out = normalizeEvent(
938
+ 'error',
939
+ {
940
+ error: {
941
+ type: 'unknown',
942
+ message: 'failed',
943
+ providerReason: 'cancelled'
944
+ },
945
+ operation: 'sendTransaction'
946
+ },
947
+ SDK_SESSION,
948
+ NOW
949
+ )
950
+ expect(out.error?.providerReason).toBe('cancelled')
951
+ })
952
+
870
953
  it('does not expose a stray connectAttemptId on data-bearing non-connect events', () => {
871
954
  const out = normalizeEvent(
872
955
  'error',
@@ -52,6 +52,20 @@ function capNetworkMismatch(
52
52
  }
53
53
  }
54
54
 
55
+ /**
56
+ * Egress shape for a wallet error. `providerReason` is wallet-controlled text, so
57
+ * it gets the same scrubber as `message` and is omitted when absent.
58
+ */
59
+ function toTelemetryError(error: WalletError): WalletError {
60
+ return {
61
+ type: error.type,
62
+ message: scrubMessage(error.message),
63
+ ...(error.providerReason
64
+ ? { providerReason: scrubMessage(error.providerReason) }
65
+ : {})
66
+ }
67
+ }
68
+
55
69
  /**
56
70
  * A normalized, PII-safe telemetry record derived from a `UWCEventMap` event.
57
71
  *
@@ -109,6 +123,8 @@ export interface UWCTelemetryEvent {
109
123
  targetAlreadyActive?: boolean | undefined
110
124
  /** connect only: a `silentOnly` attempt — tells a silent restore from a tapped connect. */
111
125
  silentOnly?: boolean | undefined
126
+ /** connect only: how the connect network was chosen — explicit, detected, or catalog order. */
127
+ networkSelection?: 'explicit' | 'detected' | 'catalogOrder' | undefined
112
128
  /** Non-success outcomes: stringified error code when reliably known. */
113
129
  errorCode?: string | undefined
114
130
  /** Non-success outcomes: coarse cause bucket. `unknown` when not classifiable. */
@@ -434,7 +450,7 @@ function buildTelemetryEvent<K extends UWCEventName>(
434
450
  return {
435
451
  ...base,
436
452
  operation: d.operation,
437
- error: { type: d.error.type, message: scrubMessage(d.error.message) },
453
+ error: toTelemetryError(d.error),
438
454
  // Telemetry-only classification — undefined for ordinary errors, set for
439
455
  // pre-wallet validation failures (e.g. no_common_network) that never
440
456
  // reach the handoff funnel.
@@ -459,7 +475,8 @@ function buildTelemetryEvent<K extends UWCEventName>(
459
475
  walletFlowType: d.walletFlowType,
460
476
  platform: d.platform,
461
477
  targetAlreadyActive: d.targetAlreadyActive,
462
- silentOnly: d.silentOnly
478
+ silentOnly: d.silentOnly,
479
+ networkSelection: d.networkSelection
463
480
  }
464
481
  }
465
482
  case 'walletSucceeded': {
@@ -478,6 +495,7 @@ function buildTelemetryEvent<K extends UWCEventName>(
478
495
  timeoutMs: d.timeoutMs,
479
496
  targetAlreadyActive: d.targetAlreadyActive,
480
497
  silentOnly: d.silentOnly,
498
+ networkSelection: d.networkSelection,
481
499
  backgroundedMs: d.backgroundedMs,
482
500
  backgroundCount: d.backgroundCount,
483
501
  relayDropCount: d.relayDropCount,
@@ -500,12 +518,11 @@ function buildTelemetryEvent<K extends UWCEventName>(
500
518
  timeoutMs: d.timeoutMs,
501
519
  targetAlreadyActive: d.targetAlreadyActive,
502
520
  silentOnly: d.silentOnly,
521
+ networkSelection: d.networkSelection,
503
522
  errorCode: d.errorCode,
504
523
  failureKind: d.failureKind,
505
524
  source: d.source,
506
- error: d.error
507
- ? { type: d.error.type, message: scrubMessage(d.error.message) }
508
- : undefined,
525
+ error: d.error ? toTelemetryError(d.error) : undefined,
509
526
  backgroundedMs: d.backgroundedMs,
510
527
  backgroundCount: d.backgroundCount,
511
528
  relayDropCount: d.relayDropCount
@@ -526,6 +543,7 @@ function buildTelemetryEvent<K extends UWCEventName>(
526
543
  timeoutMs: d.timeoutMs,
527
544
  targetAlreadyActive: d.targetAlreadyActive,
528
545
  silentOnly: d.silentOnly,
546
+ networkSelection: d.networkSelection,
529
547
  backgroundedMs: d.backgroundedMs,
530
548
  backgroundCount: d.backgroundCount,
531
549
  relayDropCount: d.relayDropCount
@@ -562,10 +580,11 @@ function buildTelemetryEvent<K extends UWCEventName>(
562
580
  timeoutMs: d.timeoutMs,
563
581
  targetAlreadyActive: d.targetAlreadyActive,
564
582
  silentOnly: d.silentOnly,
583
+ networkSelection: d.networkSelection,
565
584
  errorCode: d.errorCode,
566
585
  failureKind: d.failureKind,
567
586
  source: d.source,
568
- error: { type: d.error.type, message: scrubMessage(d.error.message) },
587
+ error: toTelemetryError(d.error),
569
588
  backgroundedMs: d.backgroundedMs,
570
589
  backgroundCount: d.backgroundCount,
571
590
  relayDropCount: d.relayDropCount
@@ -609,7 +628,7 @@ function buildTelemetryEvent<K extends UWCEventName>(
609
628
  ...base,
610
629
  connectionMode: d.connectionMode,
611
630
  namespace: d.namespace,
612
- error: { type: d.error.type, message: scrubMessage(d.error.message) }
631
+ error: toTelemetryError(d.error)
613
632
  }
614
633
  }
615
634
  case 'secondarySolanaConnectSkipped': {
@@ -651,7 +670,7 @@ function buildTelemetryEvent<K extends UWCEventName>(
651
670
  namespace: d.namespace,
652
671
  source: 'bridge',
653
672
  failureKind: 'bridge_error',
654
- error: { type: d.error.type, message: scrubMessage(d.error.message) }
673
+ error: toTelemetryError(d.error)
655
674
  }
656
675
  }
657
676
  case 'relayError': {
@@ -663,7 +682,7 @@ function buildTelemetryEvent<K extends UWCEventName>(
663
682
  connectionMode: 'walletConnect',
664
683
  source: 'provider',
665
684
  failureKind: 'network_error',
666
- error: { type: d.error.type, message: scrubMessage(d.error.message) }
685
+ error: toTelemetryError(d.error)
667
686
  }
668
687
  }
669
688
  case 'relayErrorCapped':
@@ -684,7 +703,7 @@ function buildTelemetryEvent<K extends UWCEventName>(
684
703
  connectionMode: 'walletConnect',
685
704
  source: 'provider',
686
705
  failureKind: 'client_init_failed',
687
- error: { type: d.error.type, message: scrubMessage(d.error.message) }
706
+ error: toTelemetryError(d.error)
688
707
  }
689
708
  }
690
709
  case 'walletAccountChanged':
@@ -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
@@ -376,6 +378,44 @@ describe('ConnectionService', () => {
376
378
  expect(awaiting.namespace).toBe('eip155')
377
379
  expect(awaiting.chainId).toBe('eip155:1')
378
380
  expect(awaiting.handoffId).toBe(events[1].data.handoffId)
381
+ expect(awaiting.networkSelection).toBe('explicit')
382
+ })
383
+
384
+ it('auto-picks a detected namespace over an undetected first network (ONC-2110)', async () => {
385
+ networks.push({
386
+ ...networks[0],
387
+ id: 'tron:0x2b6653dc',
388
+ internalId: 2,
389
+ name: 'Tron',
390
+ namespace: 'tron',
391
+ networkType: 'tron'
392
+ } as Network)
393
+ // Trust mobile: catalog lists Tron first, but only EVM was detected.
394
+ wallets[0].extensionInjectedProvider = {
395
+ supportedNetworkIds: ['tron:0x2b6653dc', 'eip155:1'],
396
+ namespaceMetaData: {
397
+ tron: { injectedId: 'trustWallet.tronLink', installed: false },
398
+ eip155: { eip155Name: 'Trust Wallet', installed: true }
399
+ }
400
+ } as any
401
+ const awaiting = vi.fn()
402
+ eventManager.on('awaitingWallet', awaiting)
403
+ mockConnector.connect = vi.fn().mockResolvedValue({
404
+ networkId: 'eip155:1',
405
+ address: '0x1234',
406
+ availableAddresses: [{ address: '0x1234', networkId: 'eip155:1' }]
407
+ })
408
+
409
+ await connectionService.connect('injected', 'metamask')
410
+
411
+ expect(awaiting).toHaveBeenCalledWith(
412
+ expect.objectContaining({
413
+ chainId: 'eip155:1',
414
+ namespace: 'eip155',
415
+ networkSelection: 'detected'
416
+ })
417
+ )
418
+ expect(sessionManager.getSession().activeNetwork?.id).toBe('eip155:1')
379
419
  })
380
420
 
381
421
  it('emits one stable attempt ID per connect and a different ID for the next connect', async () => {
@@ -18,6 +18,11 @@ import { raceAbort } from '../utils/abort'
18
18
  import { generateId } from '../utils/id'
19
19
  import { instrumentHandoff } from '../observability/instrument-handoff'
20
20
  import { attachLifecycle } from '../observability/lifecycle-bridge'
21
+ import {
22
+ DEFAULT_NAMESPACE_PRIORITY,
23
+ type NetworkSelection,
24
+ pickAutoNetwork
25
+ } from './network-selection'
21
26
 
22
27
  export interface ServiceOptions {
23
28
  signal?: AbortSignal
@@ -87,7 +92,9 @@ export class ConnectionService {
87
92
  private usingIntegratedBrowser: boolean,
88
93
  private eventManager: EventManager,
89
94
  // Observational wallet-response deadline for the handoff timeout marker.
90
- private walletResponseTimeoutMs: number
95
+ private walletResponseTimeoutMs: number,
96
+ /** Namespace preference for an auto-picked connect network — see `pickAutoNetwork`. */
97
+ private preferredNamespaces: readonly Namespace[] = DEFAULT_NAMESPACE_PRIORITY
91
98
  ) {}
92
99
 
93
100
  async connect(
@@ -104,7 +111,8 @@ export class ConnectionService {
104
111
  // connect() would spuriously abort (MFS-602).
105
112
  const wallet = this.findWallet(walletId)
106
113
  const provider = this.getProviderForMode(wallet, connectionMode)
107
- const network = this.validateAndDetermineNetwork(networkId, provider)
114
+ const { network, selection: networkSelection } =
115
+ this.validateAndDetermineNetwork(networkId, provider, connectionMode)
108
116
  const connector = this.getConnector(connectionMode)
109
117
  // A silent-only connect is a promise never to open wallet UI. Only the
110
118
  // injected connector can keep it — WalletConnect / TON Connect always hand
@@ -176,7 +184,8 @@ export class ConnectionService {
176
184
  connectAttemptId,
177
185
  // Lets telemetry tell a silent restore from a tapped connect: both
178
186
  // succeed the same way, and only a miss carries a distinct failureKind.
179
- silentOnly: options?.silentOnly === true
187
+ silentOnly: options?.silentOnly === true,
188
+ networkSelection
180
189
  },
181
190
  async handoffId => {
182
191
  // Publish this attempt's routing for the connector's step sink.
@@ -947,9 +956,11 @@ export class ConnectionService {
947
956
 
948
957
  private validateAndDetermineNetwork(
949
958
  networkId: NetworkId | undefined,
950
- provider: SupportedProvider
951
- ): Network {
959
+ provider: SupportedProvider,
960
+ connectionMode: ConnectionMode
961
+ ): { network: Network; selection: NetworkSelection } {
952
962
  let targetNetworkId: NetworkId
963
+ let selection: NetworkSelection = 'explicit'
953
964
 
954
965
  if (networkId) {
955
966
  // Check if networkId is in the configured networks
@@ -968,11 +979,16 @@ export class ConnectionService {
968
979
 
969
980
  targetNetworkId = networkId
970
981
  } 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)
982
+ // No network named: prefer a namespace detection found installed (injected
983
+ // only), ranked by `preferredNamespaces`; otherwise the first supported
984
+ // network that is also configured (catalog order).
985
+ const picked = pickAutoNetwork(
986
+ provider,
987
+ this.networks,
988
+ connectionMode,
989
+ this.preferredNamespaces
974
990
  )
975
- if (!supportedNetwork) {
991
+ if (!picked) {
976
992
  // The #1 production connection failure. Throw a typed, self-describing
977
993
  // error carrying both CAIP-2 id lists (non-PII) so telemetry can tell a
978
994
  // metadata gap from a client-config mistake — see NoCommonNetworkError.
@@ -981,7 +997,8 @@ export class ConnectionService {
981
997
  this.networks.map(n => n.id)
982
998
  )
983
999
  }
984
- targetNetworkId = supportedNetwork
1000
+ targetNetworkId = picked.networkId
1001
+ selection = picked.selection
985
1002
  }
986
1003
 
987
1004
  // Find and return the network object
@@ -991,7 +1008,7 @@ export class ConnectionService {
991
1008
  `Network ${targetNetworkId} not found in configured networks`
992
1009
  )
993
1010
  }
994
- return network
1011
+ return { network, selection }
995
1012
  }
996
1013
 
997
1014
  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
+ }
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
2
  import { TransactionService } from './transaction-service'
3
3
  import { SessionManager } from '../managers/session-manager'
4
4
  import { EventManager } from '../managers/event-manager'
5
+ import { WalletConnectorError } from '../../../types/src'
5
6
  import type {
6
7
  ConnectionMode,
7
8
  Connector,
@@ -211,6 +212,30 @@ describe('TransactionService.sendTransaction', () => {
211
212
  expect(events).toEqual(['awaitingWallet', 'walletRejected'])
212
213
  })
213
214
 
215
+ it("carries the wallet's own reason onto walletFailed and the thrown error", async () => {
216
+ connectSession()
217
+ const failures: { error: { providerReason?: string } }[] = []
218
+ eventManager.on('walletFailed', d => failures.push(d))
219
+ vi.mocked(mockConnector.sendTransaction!).mockRejectedValueOnce(
220
+ new WalletConnectorError({
221
+ type: 'unknown',
222
+ message: 'An unknown RPC error occurred.',
223
+ providerReason: 'cancelled'
224
+ })
225
+ )
226
+
227
+ const error = await transactionService
228
+ .sendTransaction(request)
229
+ .catch((e: unknown) => e)
230
+
231
+ expect(error).toMatchObject({ providerReason: 'cancelled' })
232
+ expect(events).toEqual(['awaitingWallet', 'walletFailed'])
233
+ expect(failures[0]?.error).toMatchObject({
234
+ type: 'unknown',
235
+ providerReason: 'cancelled'
236
+ })
237
+ })
238
+
214
239
  it('tonConnect enters the funnel with the connector resolving its own provider', async () => {
215
240
  connectors.set('tonConnect', mockConnector)
216
241
  connectSession('tonConnect', walletWithoutProviders)