@meshconnect/uwc-core 1.5.0 → 1.7.0-snapshot.56a9011

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.
package/src/events.ts CHANGED
@@ -56,7 +56,10 @@ export interface UWCEventMap {
56
56
  walletsDetected: {
57
57
  wallets: WalletMetadata[]
58
58
  discovery?:
59
- | Record<string, { requested: number; matched: number }>
59
+ | Record<
60
+ string,
61
+ { requested: number; matched: number; legacyInjected?: number }
62
+ >
60
63
  | undefined
61
64
  }
62
65
  /**
@@ -234,6 +237,13 @@ export interface UWCEventMap {
234
237
  handoffId: string
235
238
  operation: UWCOperation
236
239
  durationMs: number
240
+ /**
241
+ * The rejection error, PII-light and scrubbed on egress. Present so triage can
242
+ * see WHY a reject fired (e.g. the wallet's own decline string) without a join.
243
+ * Optional so adding it stays non-breaking for consumers of this exported event
244
+ * type; `instrumentHandoff` always sets it.
245
+ */
246
+ error?: WalletError | undefined
237
247
  connectionMode: ConnectionMode
238
248
  walletId?: string | undefined
239
249
  namespace?: Namespace | undefined
@@ -366,6 +376,24 @@ export interface UWCEventMap {
366
376
  hiddenMs?: number | undefined
367
377
  }
368
378
 
379
+ /**
380
+ * The framed secondary Solana gather was SKIPPED for a wallet that declares
381
+ * a legacy `solana.injectedId` (ONC-3536 follow-up). In such wallets'
382
+ * in-app browsers (Coinbase/Base Wallet Legacy mode) any secondary Solana
383
+ * connect after an EVM connect parks the wallet's active namespace on
384
+ * Solana, and the following EVM `eth_sendTransaction` never surfaces an
385
+ * approval sheet (60s `walletTimedOut`, the USDC.base hang). Triage
386
+ * telemetry: confirms the gate fired when retesting the fix. Deduped per
387
+ * injectedId per connector instance. Carries the metadata key + discovery
388
+ * mode only — never an address.
389
+ */
390
+ secondarySolanaConnectSkipped: {
391
+ /** The `solana.injectedId` metadata key that triggered the skip (e.g. `coinbaseSolana`). */
392
+ injectedId: string
393
+ /** True = wallet discovered via the legacy injected fallback (true "Legacy mode"); false = Wallet Standard. */
394
+ legacyInjected: boolean
395
+ }
396
+
369
397
  /**
370
398
  * Back-compat catch-all. Fires after every other event above.
371
399
  * New code should prefer the specific events.
@@ -397,7 +425,8 @@ export const TELEMETRY_ONLY_EVENTS: ReadonlySet<UWCEventName> = new Set([
397
425
  'walletSessionExpired',
398
426
  'lifecycleTelemetryUnavailable',
399
427
  'pageHiddenDuringHandoff',
400
- 'pageVisibleDuringHandoff'
428
+ 'pageVisibleDuringHandoff',
429
+ 'secondarySolanaConnectSkipped'
401
430
  ])
402
431
 
403
432
  export type UWCEventName = keyof UWCEventMap
@@ -442,6 +471,7 @@ const EVENT_NAME_FLAGS: Record<UWCEventName, true> = {
442
471
  lifecycleTelemetryUnavailable: true,
443
472
  pageHiddenDuringHandoff: true,
444
473
  pageVisibleDuringHandoff: true,
474
+ secondarySolanaConnectSkipped: true,
445
475
  change: true
446
476
  }
447
477
 
@@ -137,10 +137,31 @@ describe('instrumentHandoff', () => {
137
137
  errorCode?: string
138
138
  failureKind?: string
139
139
  source?: string
140
+ error?: { type: string; message: string }
140
141
  }
141
142
  expect(rejected.errorCode).toBe('4001')
142
143
  expect(rejected.failureKind).toBe('user_rejected')
143
144
  expect(rejected.source).toBe('provider')
145
+ // walletRejected now carries the error (mirroring walletFailed) so triage
146
+ // sees WHY the reject fired without joining to another event. type is forced
147
+ // to 'rejected' — a raw 4001 Error would otherwise normalize to 'unknown',
148
+ // which would contradict the walletRejected terminal.
149
+ expect(rejected.error).toEqual({ type: 'rejected', message: 'rejected' })
150
+ })
151
+
152
+ it('a WalletConnectorError{rejected} keeps type rejected on the walletRejected error', async () => {
153
+ const em = new EventManager()
154
+ const seen = record(em)
155
+ await expect(
156
+ instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, async () => {
157
+ throw new WalletConnectorError({ type: 'rejected', message: 'user no' })
158
+ })
159
+ ).rejects.toThrow()
160
+ const rejected = seen[1].data as {
161
+ error?: { type: string; message: string }
162
+ }
163
+ expect(seen[1].name).toBe('walletRejected')
164
+ expect(rejected.error).toEqual({ type: 'rejected', message: 'user no' })
144
165
  })
145
166
 
146
167
  it('classifies an unmapped failure as unknown (no guess)', async () => {
@@ -230,6 +230,12 @@ export async function instrumentHandoff<T>(
230
230
  handoffId,
231
231
  operation: context.operation,
232
232
  durationMs,
233
+ // We are in the isUserRejection branch — a reliable structural rejection
234
+ // signal — so the type is 'rejected' even for a raw EIP-1193 4001 Error
235
+ // (which toWalletError would otherwise report as 'unknown'). Keep the
236
+ // normalized/scrubbed message; correct the type so error.type never
237
+ // contradicts the walletRejected terminal.
238
+ error: { ...toWalletError(error), type: 'rejected' },
233
239
  timedOut,
234
240
  timeoutMs: timedOut ? timeoutMs : undefined,
235
241
  errorCode,
@@ -320,6 +320,7 @@ describe('normalizeEvent', () => {
320
320
  handoffId: 'h-1',
321
321
  operation: 'signMessage',
322
322
  durationMs: 5,
323
+ error: { type: 'rejected', message: 'User rejected the request' },
323
324
  connectionMode: 'injected',
324
325
  timedOut: false,
325
326
  errorCode: '4001',
@@ -332,6 +333,11 @@ describe('normalizeEvent', () => {
332
333
  expect(rejected.errorCode).toBe('4001')
333
334
  expect(rejected.failureKind).toBe('user_rejected')
334
335
  expect(rejected.source).toBe('provider')
336
+ // The rejection error rides along so triage sees WHY, mirroring walletFailed.
337
+ expect(rejected.error).toEqual({
338
+ type: 'rejected',
339
+ message: 'User rejected the request'
340
+ })
335
341
 
336
342
  const failed = normalizeEvent(
337
343
  'walletFailed',
@@ -352,6 +358,48 @@ describe('normalizeEvent', () => {
352
358
  expect(failed.source).toBe('unknown')
353
359
  })
354
360
 
361
+ it('scrubs a PII address out of the walletRejected error message on egress', () => {
362
+ const addr = '0x' + 'a'.repeat(40)
363
+ const out = normalizeEvent(
364
+ 'walletRejected',
365
+ {
366
+ handoffId: 'h-1',
367
+ operation: 'connect',
368
+ durationMs: 5,
369
+ error: { type: 'rejected', message: `declined for ${addr}` },
370
+ connectionMode: 'injected',
371
+ timedOut: false,
372
+ failureKind: 'user_rejected',
373
+ source: 'provider'
374
+ },
375
+ SDK_SESSION,
376
+ NOW
377
+ )
378
+ expect(out.error?.message).not.toContain(addr)
379
+ expect(out.error?.message).toContain('[redacted-hex]')
380
+ })
381
+
382
+ it('maps a walletRejected without error to error:undefined (optional field, no throw)', () => {
383
+ // `error` is optional on the exported walletRejected type; the mapping must
384
+ // not assume it. Guards against a regression if a caller omits it.
385
+ const out = normalizeEvent(
386
+ 'walletRejected',
387
+ {
388
+ handoffId: 'h-1',
389
+ operation: 'connect',
390
+ durationMs: 5,
391
+ connectionMode: 'injected',
392
+ timedOut: false,
393
+ failureKind: 'user_rejected',
394
+ source: 'provider'
395
+ },
396
+ SDK_SESSION,
397
+ NOW
398
+ )
399
+ expect(out.error).toBeUndefined()
400
+ expect(out.failureKind).toBe('user_rejected')
401
+ })
402
+
355
403
  it('summarizes capabilitiesUpdated to per-chain supported feature names', () => {
356
404
  const out = normalizeEvent(
357
405
  'capabilitiesUpdated',
@@ -433,6 +481,26 @@ describe('normalizeEvent', () => {
433
481
  expect(out.error?.message).toBe('bridge unreachable')
434
482
  })
435
483
 
484
+ it('carries injectedId + legacyInjected for secondarySolanaConnectSkipped, length-capped (ONC-3536)', () => {
485
+ const out = normalizeEvent(
486
+ 'secondarySolanaConnectSkipped',
487
+ { injectedId: 'coinbaseSolana', legacyInjected: true },
488
+ SDK_SESSION,
489
+ NOW
490
+ )
491
+ expect(out.injectedId).toBe('coinbaseSolana')
492
+ expect(out.legacyInjected).toBe(true)
493
+
494
+ const capped = normalizeEvent(
495
+ 'secondarySolanaConnectSkipped',
496
+ { injectedId: 'x'.repeat(200), legacyInjected: false },
497
+ SDK_SESSION,
498
+ NOW
499
+ )
500
+ expect(capped.injectedId).toHaveLength(64)
501
+ expect(capped.legacyInjected).toBe(false)
502
+ })
503
+
436
504
  it('normalizes lifecycle events with routing only, no address (P2)', () => {
437
505
  const acct = normalizeEvent(
438
506
  'walletAccountChanged',
@@ -31,6 +31,9 @@ export const TELEMETRY_SCHEMA_VERSION = 1
31
31
  const MAX_NETWORK_MISMATCH_IDS = 20
32
32
  const MAX_NETWORK_ID_LENGTH = 64
33
33
 
34
+ /** Egress cap for `injectedId` (a catalog-metadata window-global key). */
35
+ const MAX_INJECTED_ID_LENGTH = 64
36
+
34
37
  function capNetworkMismatch(
35
38
  mismatch:
36
39
  | { walletNetworkIds: string[]; configuredNetworkIds: string[] }
@@ -125,9 +128,16 @@ export interface UWCTelemetryEvent {
125
128
  capabilities?: Record<string, string[]> | undefined
126
129
  /**
127
130
  * Per-namespace requested-vs-matched discovery summary, present only on
128
- * `walletsDetected`. Counts only — never a wallet id or address.
131
+ * `walletsDetected`. Counts only — never a wallet id or address. `legacyInjected`
132
+ * (solana-only) counts matched wallets found via the legacy injected-Solana
133
+ * fallback rather than the Wallet Standard registry (MFS-805); omitted when 0.
129
134
  */
130
- discovery?: Record<string, { requested: number; matched: number }> | undefined
135
+ discovery?:
136
+ | Record<
137
+ string,
138
+ { requested: number; matched: number; legacyInjected?: number }
139
+ >
140
+ | undefined
131
141
  /**
132
142
  * Per-namespace count of the session's available addresses, present on
133
143
  * `connected` / `sessionChanged`. Counts only — never an address.
@@ -162,6 +172,19 @@ export interface UWCTelemetryEvent {
162
172
  backgroundCount?: number | undefined
163
173
  /** Handoff terminals, WalletConnect only: relay-socket drops seen while the handoff was pending. */
164
174
  relayDropCount?: number | undefined
175
+ /**
176
+ * `secondarySolanaConnectSkipped` only: the `solana.injectedId` metadata key
177
+ * whose framed secondary gather was suppressed (e.g. `coinbaseSolana`) —
178
+ * a consumer-configured metadata key, never an address. Length-capped as
179
+ * defense-in-depth since the value originates in catalog metadata.
180
+ */
181
+ injectedId?: string | undefined
182
+ /**
183
+ * `secondarySolanaConnectSkipped` only: true when the skipped wallet was
184
+ * discovered via the legacy injected-Solana fallback (true "Legacy mode")
185
+ * rather than the Wallet Standard registry.
186
+ */
187
+ legacyInjected?: boolean | undefined
165
188
  }
166
189
 
167
190
  /**
@@ -393,6 +416,9 @@ function buildTelemetryEvent<K extends UWCEventName>(
393
416
  errorCode: d.errorCode,
394
417
  failureKind: d.failureKind,
395
418
  source: d.source,
419
+ error: d.error
420
+ ? { type: d.error.type, message: scrubMessage(d.error.message) }
421
+ : undefined,
396
422
  backgroundedMs: d.backgroundedMs,
397
423
  backgroundCount: d.backgroundCount,
398
424
  relayDropCount: d.relayDropCount
@@ -466,6 +492,14 @@ function buildTelemetryEvent<K extends UWCEventName>(
466
492
  error: { type: d.error.type, message: scrubMessage(d.error.message) }
467
493
  }
468
494
  }
495
+ case 'secondarySolanaConnectSkipped': {
496
+ const d = data as UWCEventMap['secondarySolanaConnectSkipped']
497
+ return {
498
+ ...base,
499
+ injectedId: d.injectedId.slice(0, MAX_INJECTED_ID_LENGTH),
500
+ legacyInjected: d.legacyInjected
501
+ }
502
+ }
469
503
  case 'pageHiddenDuringHandoff':
470
504
  case 'pageVisibleDuringHandoff': {
471
505
  // Cast to the visible shape (superset). `hiddenMs` is absent on the hidden
@@ -539,6 +539,118 @@ describe('UniversalWalletConnector — observability wiring', () => {
539
539
  expect(detected.discovery).toEqual({ eip155: { requested: 1, matched: 0 } })
540
540
  })
541
541
 
542
+ it('matches a Solana wallet by the in-wallet provider name when usingIntegratedBrowser (ONC-3536)', async () => {
543
+ // Regression guard for the Coinbase/Base legacy fallback: the extension and
544
+ // in-wallet Solana names differ ("Base Wallet" vs "Base"). With
545
+ // usingIntegratedBrowser, core must match the DISCOVERED name against the
546
+ // in-wallet provider — matching the extension name would miss (matched: 0).
547
+ const SOL_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'
548
+ getAvailableWalletsMock.mockImplementation((ns: string) =>
549
+ Promise.resolve(
550
+ ns === 'solana' ? [{ uuid: 'sol-1', name: 'Base', features: [] }] : []
551
+ )
552
+ )
553
+ const wallet = {
554
+ id: 'coinbase',
555
+ name: 'Base Wallet',
556
+ extensionInjectedProvider: {
557
+ supportedNetworkIds: [SOL_ID],
558
+ namespaceMetaData: {
559
+ solana: {
560
+ walletStandardName: 'Base Wallet',
561
+ injectedId: 'coinbaseSolana'
562
+ }
563
+ }
564
+ },
565
+ integratedBrowserInjectedProvider: {
566
+ supportedNetworkIds: [SOL_ID],
567
+ namespaceMetaData: {
568
+ solana: { walletStandardName: 'Base', injectedId: 'coinbaseSolana' }
569
+ }
570
+ }
571
+ } as unknown as WalletMetadata
572
+
573
+ const events: UWCTelemetryEvent[] = []
574
+ let resolveReady!: () => void
575
+ const ready = new Promise<void>(r => (resolveReady = r))
576
+ new UniversalWalletConnector({
577
+ networks,
578
+ wallets: [wallet],
579
+ usingIntegratedBrowser: true,
580
+ observer: {
581
+ onEvent: e => {
582
+ events.push(e)
583
+ if (e.name === 'ready') resolveReady()
584
+ }
585
+ }
586
+ })
587
+ await ready
588
+
589
+ const detected = events.find(e => e.name === 'walletsDetected')!
590
+ expect(detected.discovery).toEqual({ solana: { requested: 1, matched: 1 } })
591
+ })
592
+
593
+ it('walletsDetected discovery.solana counts legacyInjected for the fallback path (MFS-805)', async () => {
594
+ // A solana wallet detected via the legacy injected fallback (window-global)
595
+ // carries legacyInjected — the summary must surface that count so a dashboard
596
+ // can confirm the legacy path (not the Wallet Standard registry) was used.
597
+ const SOL_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'
598
+ getAvailableWalletsMock.mockImplementation((ns: string) =>
599
+ Promise.resolve(
600
+ ns === 'solana'
601
+ ? [
602
+ {
603
+ uuid: 'sol-1',
604
+ name: 'Base',
605
+ features: [],
606
+ legacyInjected: true
607
+ }
608
+ ]
609
+ : []
610
+ )
611
+ )
612
+ const wallet = {
613
+ id: 'coinbase',
614
+ name: 'Base Wallet',
615
+ extensionInjectedProvider: {
616
+ supportedNetworkIds: [SOL_ID],
617
+ namespaceMetaData: {
618
+ solana: {
619
+ walletStandardName: 'Base Wallet',
620
+ injectedId: 'coinbaseSolana'
621
+ }
622
+ }
623
+ },
624
+ integratedBrowserInjectedProvider: {
625
+ supportedNetworkIds: [SOL_ID],
626
+ namespaceMetaData: {
627
+ solana: { walletStandardName: 'Base', injectedId: 'coinbaseSolana' }
628
+ }
629
+ }
630
+ } as unknown as WalletMetadata
631
+
632
+ const events: UWCTelemetryEvent[] = []
633
+ let resolveReady!: () => void
634
+ const ready = new Promise<void>(r => (resolveReady = r))
635
+ new UniversalWalletConnector({
636
+ networks,
637
+ wallets: [wallet],
638
+ usingIntegratedBrowser: true,
639
+ observer: {
640
+ onEvent: e => {
641
+ events.push(e)
642
+ if (e.name === 'ready') resolveReady()
643
+ }
644
+ }
645
+ })
646
+ await ready
647
+
648
+ const detected = events.find(e => e.name === 'walletsDetected')!
649
+ expect(detected.discovery).toEqual({
650
+ solana: { requested: 1, matched: 1, legacyInjected: 1 }
651
+ })
652
+ })
653
+
542
654
  it('a missing provider fails BEFORE the funnel — config error, not a wallet outcome', async () => {
543
655
  // Design parity with connect/switch: provider resolution is validation, so
544
656
  // its failure must surface only via the `error` event, never as an
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import type { Network, WalletMetadata } from '@meshconnect/uwc-types'
3
+ import { solanaNetwork } from '@meshconnect/uwc-constants'
4
+ import { UniversalWalletConnector } from './universal-wallet-connector'
5
+ import type { UWCObserver, UWCTelemetryEvent } from './observability/telemetry'
6
+
7
+ // End-to-end seam test (ONC-3536 review): the other observability tests stub
8
+ // `getAvailableWallets`, so they prove discovery EMITS a synthetic entry but not
9
+ // that core actually MATCHES it. Here the real `InjectedConnector` +
10
+ // `getSolanaWallets` run — only the non-injected connectors are stubbed (they'd
11
+ // otherwise attempt real WC/TON init) and the Wallet Standard registry is mocked
12
+ // empty so the legacy window-global path is the one exercised.
13
+ //
14
+ // `discovery.solana.matched` counts wallets marked `installed`, and `installed`
15
+ // is set ONLY by core's `applyDetectedWallet` when the discovered name matches
16
+ // the session provider's `walletStandardName`. So `matched: 1` here IS proof
17
+ // that the real discovery output flowed through core detection and matched.
18
+ vi.mock('@meshconnect/uwc-wallet-connect-connector', () => ({
19
+ WalletConnectConnector: vi.fn().mockImplementation(() => ({
20
+ getAvailableWallets: vi.fn().mockResolvedValue([])
21
+ }))
22
+ }))
23
+ vi.mock('@meshconnect/uwc-ton-connector', () => ({
24
+ TonConnectConnector: vi.fn().mockImplementation(() => ({
25
+ getAvailableWallets: vi.fn().mockResolvedValue([])
26
+ }))
27
+ }))
28
+ // Empty Wallet Standard registry → forces the legacy `window[injectedId]` path.
29
+ vi.mock('@wallet-standard/app', () => ({
30
+ getWallets: () => ({ get: () => [], on: () => () => {} })
31
+ }))
32
+
33
+ const SOL_ID = solanaNetwork.id
34
+ const networks = [solanaNetwork] as unknown as Network[]
35
+
36
+ // A minimal but VALID legacy injected-Solana provider — all 8 methods the real
37
+ // `isLegacyInjectedSolanaProvider` duck-type check requires, so
38
+ // `window.coinbaseSolana` is accepted by the real adapter.
39
+ function makeLegacyProvider() {
40
+ return {
41
+ connect: vi.fn(),
42
+ disconnect: vi.fn(),
43
+ on: vi.fn(),
44
+ off: vi.fn(),
45
+ signTransaction: vi.fn(),
46
+ signAllTransactions: vi.fn(),
47
+ signMessage: vi.fn(),
48
+ signAndSendTransaction: vi.fn()
49
+ }
50
+ }
51
+
52
+ describe('UWC — legacy injected-Solana discovery → core match (end-to-end)', () => {
53
+ beforeEach(() => {
54
+ UniversalWalletConnector.resetInstance()
55
+ ;(window as unknown as Record<string, unknown>).coinbaseSolana =
56
+ makeLegacyProvider()
57
+ })
58
+ afterEach(() => {
59
+ delete (window as unknown as Record<string, unknown>).coinbaseSolana
60
+ })
61
+
62
+ it('matches the real synthetic entry end-to-end and reports legacyInjected:1', async () => {
63
+ // In-wallet browser session: the solana block lives on the integrated
64
+ // provider ("Base"), exactly what core's pickProvider reads.
65
+ const wallet = {
66
+ id: 'coinbase',
67
+ name: 'Base Wallet',
68
+ integratedBrowserInjectedProvider: {
69
+ supportedNetworkIds: [SOL_ID],
70
+ namespaceMetaData: {
71
+ solana: { walletStandardName: 'Base', injectedId: 'coinbaseSolana' }
72
+ }
73
+ }
74
+ } as unknown as WalletMetadata
75
+
76
+ const events: UWCTelemetryEvent[] = []
77
+ let resolveReady!: () => void
78
+ const ready = new Promise<void>(r => (resolveReady = r))
79
+ const observer: UWCObserver = {
80
+ onEvent: e => {
81
+ events.push(e)
82
+ if (e.name === 'ready') resolveReady()
83
+ }
84
+ }
85
+
86
+ new UniversalWalletConnector({
87
+ networks,
88
+ wallets: [wallet],
89
+ usingIntegratedBrowser: true,
90
+ observer
91
+ })
92
+ await ready
93
+
94
+ const detected = events.find(e => e.name === 'walletsDetected')!
95
+ // matched:1 ⇒ core matched the real getSolanaWallets() output and marked it
96
+ // installed; legacyInjected:1 ⇒ it came via the window-global fallback.
97
+ expect(detected.discovery).toEqual({
98
+ solana: { requested: 1, matched: 1, legacyInjected: 1 }
99
+ })
100
+ })
101
+
102
+ it('does NOT match when the legacy global is absent (fail-closed)', async () => {
103
+ delete (window as unknown as Record<string, unknown>).coinbaseSolana
104
+
105
+ const wallet = {
106
+ id: 'coinbase',
107
+ name: 'Base Wallet',
108
+ integratedBrowserInjectedProvider: {
109
+ supportedNetworkIds: [SOL_ID],
110
+ namespaceMetaData: {
111
+ solana: { walletStandardName: 'Base', injectedId: 'coinbaseSolana' }
112
+ }
113
+ }
114
+ } as unknown as WalletMetadata
115
+
116
+ const events: UWCTelemetryEvent[] = []
117
+ let resolveReady!: () => void
118
+ const ready = new Promise<void>(r => (resolveReady = r))
119
+ new UniversalWalletConnector({
120
+ networks,
121
+ wallets: [wallet],
122
+ usingIntegratedBrowser: true,
123
+ observer: {
124
+ onEvent: e => {
125
+ events.push(e)
126
+ if (e.name === 'ready') resolveReady()
127
+ }
128
+ }
129
+ })
130
+ await ready
131
+
132
+ const detected = events.find(e => e.name === 'walletsDetected')!
133
+ // No global → no synthetic entry → not installed → legacyInjected omitted.
134
+ expect(detected.discovery).toEqual({ solana: { requested: 1, matched: 0 } })
135
+ })
136
+ })
@@ -284,20 +284,38 @@ export class UniversalWalletConnector {
284
284
 
285
285
  // Initialize connectors with RPC map
286
286
  this.connectors = new Map()
287
- this.injectedConnector = new InjectedConnector(
288
- this.networkRpcMap,
289
- this.wallets,
290
- configuredTonConnectConfig,
291
- configuredTronConnectorConfig,
287
+ this.injectedConnector = new InjectedConnector({
288
+ networkRpcMap: this.networkRpcMap,
289
+ expectedWallets: this.wallets,
290
+ tonConnectConfig: configuredTonConnectConfig,
291
+ tronConnectorConfig: configuredTronConnectorConfig,
292
292
  networks,
293
293
  // Surface BridgeChild discovery failures (the Bitstarz "wallet never
294
294
  // appeared via the bridge" class) as a telemetry event.
295
- info =>
295
+ onBridgeError: info =>
296
296
  this.eventManager.emit('bridgeError', {
297
297
  namespace: info.namespace as Namespace,
298
298
  error: { type: 'unknown', message: info.message }
299
- })
300
- )
299
+ }),
300
+ usingIntegratedBrowser: this.usingIntegratedBrowser,
301
+ // A configured legacy injected-Solana global is present but the wrong shape
302
+ // (the wallet changed its provider) — surface as a discovery failure so the
303
+ // otherwise-silent breakage is observable in prod telemetry.
304
+ onLegacyDiscoveryFailure: info =>
305
+ this.eventManager.emit('walletDiscoveryFailed', {
306
+ namespace: 'solana',
307
+ connectionMode: 'injected',
308
+ error: {
309
+ type: 'unknown',
310
+ message: `legacy injected-Solana provider at "${info.injectedId}" for ${info.walletName} is not a valid Solana provider`
311
+ }
312
+ }),
313
+ // Triage telemetry (ONC-3536 follow-up): the framed secondary Solana
314
+ // gather was suppressed for a legacy-`injectedId` wallet so an EVM send
315
+ // can't be parked on the Solana namespace (Coinbase Legacy-mode hang).
316
+ onSecondarySolanaConnectSkipped: info =>
317
+ this.eventManager.emit('secondarySolanaConnectSkipped', info)
318
+ })
301
319
  this.connectors.set('injected', this.injectedConnector)
302
320
 
303
321
  if (configuredTonConnectConfig) {
@@ -576,7 +594,12 @@ export class UniversalWalletConnector {
576
594
  m.detectedWallet = {
577
595
  uuid: d.uuid,
578
596
  name: d.name,
579
- features: d.features
597
+ features: d.features,
598
+ // Preserve which discovery path matched (registry vs legacy injected
599
+ // fallback) so summarizeDiscovery can report it (MFS-805).
600
+ ...(d.legacyInjected !== undefined && {
601
+ legacyInjected: d.legacyInjected
602
+ })
580
603
  }
581
604
  } else if (namespace === 'tron') {
582
605
  const m = nsMetadata as {
@@ -610,25 +633,45 @@ export class UniversalWalletConnector {
610
633
  */
611
634
  private summarizeDiscovery(
612
635
  namespaces: Set<string>
613
- ): Record<string, { requested: number; matched: number }> {
636
+ ): Record<
637
+ string,
638
+ { requested: number; matched: number; legacyInjected?: number }
639
+ > {
614
640
  const pickProvider = (w: WalletMetadata) =>
615
641
  this.usingIntegratedBrowser
616
642
  ? w.integratedBrowserInjectedProvider
617
643
  : w.extensionInjectedProvider
618
644
 
619
- const summary: Record<string, { requested: number; matched: number }> = {}
645
+ const summary: Record<
646
+ string,
647
+ { requested: number; matched: number; legacyInjected?: number }
648
+ > = {}
620
649
  for (const namespace of namespaces) {
621
650
  let requested = 0
622
651
  let matched = 0
652
+ // How many matched wallets came from the legacy injected-Solana fallback
653
+ // rather than the Wallet Standard registry (MFS-805). Solana-only in
654
+ // practice; other namespaces never set the flag, so it stays 0/omitted.
655
+ let legacyInjected = 0
623
656
  for (const wallet of this.wallets) {
624
657
  const nsMeta = pickProvider(wallet)?.namespaceMetaData?.[
625
658
  namespace as Namespace
626
- ] as { installed?: boolean } | undefined
659
+ ] as
660
+ | {
661
+ installed?: boolean
662
+ detectedWallet?: { legacyInjected?: boolean }
663
+ }
664
+ | undefined
627
665
  if (!nsMeta) continue
628
666
  requested++
629
667
  if (nsMeta.installed) matched++
668
+ if (nsMeta.detectedWallet?.legacyInjected) legacyInjected++
669
+ }
670
+ summary[namespace] = {
671
+ requested,
672
+ matched,
673
+ ...(legacyInjected > 0 && { legacyInjected })
630
674
  }
631
- summary[namespace] = { requested, matched }
632
675
  }
633
676
  return summary
634
677
  }