@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/dist/events.d.ts +25 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +3 -1
- package/dist/events.js.map +1 -1
- package/dist/observability/instrument-handoff.d.ts.map +1 -1
- package/dist/observability/instrument-handoff.js +6 -0
- package/dist/observability/instrument-handoff.js.map +1 -1
- package/dist/observability/telemetry.d.ts +17 -1
- package/dist/observability/telemetry.d.ts.map +1 -1
- package/dist/observability/telemetry.js +13 -0
- package/dist/observability/telemetry.js.map +1 -1
- package/dist/universal-wallet-connector.d.ts.map +1 -1
- package/dist/universal-wallet-connector.js +46 -9
- package/dist/universal-wallet-connector.js.map +1 -1
- package/dist/utils/to-wallet-error.d.ts.map +1 -1
- package/dist/utils/to-wallet-error.js +11 -6
- package/dist/utils/to-wallet-error.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -5
- package/src/events.ts +32 -2
- package/src/observability/instrument-handoff.test.ts +21 -0
- package/src/observability/instrument-handoff.ts +6 -0
- package/src/observability/telemetry.test.ts +68 -0
- package/src/observability/telemetry.ts +36 -2
- package/src/universal-wallet-connector.observability.test.ts +112 -0
- package/src/universal-wallet-connector.solana-legacy-e2e.test.ts +136 -0
- package/src/universal-wallet-connector.ts +56 -13
- package/src/utils/to-wallet-error.test.ts +43 -0
- package/src/utils/to-wallet-error.ts +15 -12
- package/src/version.ts +1 -1
package/src/events.ts
CHANGED
|
@@ -56,7 +56,10 @@ export interface UWCEventMap {
|
|
|
56
56
|
walletsDetected: {
|
|
57
57
|
wallets: WalletMetadata[]
|
|
58
58
|
discovery?:
|
|
59
|
-
| Record<
|
|
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?:
|
|
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<
|
|
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<
|
|
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
|
|
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
|
}
|