@meshconnect/uwc-core 1.0.0 → 1.0.1-snapshot.c7e65ce

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 (76) hide show
  1. package/README.md +211 -66
  2. package/dist/events.d.ts +69 -2
  3. package/dist/events.d.ts.map +1 -1
  4. package/dist/events.js +40 -1
  5. package/dist/events.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/logger/create-logger.d.ts +32 -0
  11. package/dist/logger/create-logger.d.ts.map +1 -0
  12. package/dist/logger/create-logger.js +69 -0
  13. package/dist/logger/create-logger.js.map +1 -0
  14. package/dist/managers/event-manager.d.ts.map +1 -1
  15. package/dist/managers/event-manager.js +5 -1
  16. package/dist/managers/event-manager.js.map +1 -1
  17. package/dist/observability/connect-observer.d.ts +22 -0
  18. package/dist/observability/connect-observer.d.ts.map +1 -0
  19. package/dist/observability/connect-observer.js +60 -0
  20. package/dist/observability/connect-observer.js.map +1 -0
  21. package/dist/observability/instrument-handoff.d.ts +39 -0
  22. package/dist/observability/instrument-handoff.d.ts.map +1 -0
  23. package/dist/observability/instrument-handoff.js +86 -0
  24. package/dist/observability/instrument-handoff.js.map +1 -0
  25. package/dist/observability/scrub.d.ts +30 -0
  26. package/dist/observability/scrub.d.ts.map +1 -0
  27. package/dist/observability/scrub.js +135 -0
  28. package/dist/observability/scrub.js.map +1 -0
  29. package/dist/observability/telemetry.d.ts +77 -0
  30. package/dist/observability/telemetry.d.ts.map +1 -0
  31. package/dist/observability/telemetry.js +138 -0
  32. package/dist/observability/telemetry.js.map +1 -0
  33. package/dist/services/connection-service.d.ts +2 -1
  34. package/dist/services/connection-service.d.ts.map +1 -1
  35. package/dist/services/connection-service.js +35 -15
  36. package/dist/services/connection-service.js.map +1 -1
  37. package/dist/services/network-switch-service.d.ts +2 -1
  38. package/dist/services/network-switch-service.d.ts.map +1 -1
  39. package/dist/services/network-switch-service.js +33 -15
  40. package/dist/services/network-switch-service.js.map +1 -1
  41. package/dist/universal-wallet-connector.d.ts +51 -2
  42. package/dist/universal-wallet-connector.d.ts.map +1 -1
  43. package/dist/universal-wallet-connector.js +163 -61
  44. package/dist/universal-wallet-connector.js.map +1 -1
  45. package/dist/utils/id.d.ts +2 -0
  46. package/dist/utils/id.d.ts.map +1 -0
  47. package/dist/utils/id.js +41 -0
  48. package/dist/utils/id.js.map +1 -0
  49. package/dist/utils/to-wallet-error.d.ts +16 -0
  50. package/dist/utils/to-wallet-error.d.ts.map +1 -0
  51. package/dist/utils/to-wallet-error.js +33 -0
  52. package/dist/utils/to-wallet-error.js.map +1 -0
  53. package/package.json +5 -5
  54. package/src/events.ts +114 -1
  55. package/src/index.ts +6 -0
  56. package/src/logger/create-logger.test.ts +111 -0
  57. package/src/logger/create-logger.ts +101 -0
  58. package/src/managers/event-manager.test.ts +25 -0
  59. package/src/managers/event-manager.ts +5 -1
  60. package/src/observability/connect-observer.test.ts +100 -0
  61. package/src/observability/connect-observer.ts +71 -0
  62. package/src/observability/instrument-handoff.test.ts +150 -0
  63. package/src/observability/instrument-handoff.ts +122 -0
  64. package/src/observability/scrub.test.ts +109 -0
  65. package/src/observability/scrub.ts +142 -0
  66. package/src/observability/telemetry.test.ts +134 -0
  67. package/src/observability/telemetry.ts +211 -0
  68. package/src/services/connection-service.test.ts +66 -1
  69. package/src/services/connection-service.ts +54 -32
  70. package/src/services/network-switch-service.test.ts +51 -1
  71. package/src/services/network-switch-service.ts +43 -23
  72. package/src/universal-wallet-connector.observability.test.ts +265 -0
  73. package/src/universal-wallet-connector.ts +244 -71
  74. package/src/utils/id.test.ts +15 -0
  75. package/src/utils/id.ts +48 -0
  76. package/src/utils/to-wallet-error.ts +36 -0
package/src/events.ts CHANGED
@@ -4,7 +4,12 @@ import type {
4
4
  WalletMetadata,
5
5
  ConnectionMode,
6
6
  WalletError,
7
- EVMCapabilities
7
+ EVMCapabilities,
8
+ LogLevel,
9
+ WalletFlowType,
10
+ Platform,
11
+ Namespace,
12
+ NetworkId
8
13
  } from '@meshconnect/uwc-types'
9
14
 
10
15
  /**
@@ -15,7 +20,9 @@ export type UWCOperation =
15
20
  | 'disconnect'
16
21
  | 'switchNetwork'
17
22
  | 'signMessage'
23
+ | 'signTypedData'
18
24
  | 'sendTransaction'
25
+ | 'signSolanaTransaction'
19
26
  | 'getWalletCapabilities'
20
27
  | 'initialize'
21
28
 
@@ -60,6 +67,69 @@ export interface UWCEventMap {
60
67
  /** A user-initiated operation failed. */
61
68
  error: { error: WalletError; operation: UWCOperation }
62
69
 
70
+ // ---- Observability (telemetry-only; see TELEMETRY_ONLY_EVENTS) -------------
71
+ // These describe what happened, not a state change, so they intentionally do
72
+ // NOT cascade to the legacy `change` channel (which drives React re-renders).
73
+
74
+ /**
75
+ * A line emitted through the internal `Logger`. Fires for ALL levels regardless
76
+ * of the console threshold so an observer can capture `debug`/`info` even when
77
+ * the console is quiet. `args` is scrubbed before reaching `observer.onLog`.
78
+ */
79
+ log: { level: LogLevel; message: string; args: unknown[] }
80
+
81
+ /**
82
+ * Emitted immediately BEFORE control is handed to the wallet for a boundary op
83
+ * (the moment the blind window opens). Pair with exactly one terminal event via
84
+ * `handoffId`. Carries no signing payload and no address — only routing facets.
85
+ */
86
+ awaitingWallet: {
87
+ operation: UWCOperation
88
+ connectionMode: ConnectionMode
89
+ walletId?: string | undefined
90
+ /** CAIP namespace (eip155 | solana | tron | tvm …) — not an address. */
91
+ namespace?: Namespace | undefined
92
+ /** CAIP chain id (e.g. `eip155:1`) — not an address. */
93
+ chainId?: NetworkId | undefined
94
+ handoffId: string
95
+ /** Derived, best-effort flow taxonomy (heuristic — see deriveWalletFlowType). */
96
+ walletFlowType: WalletFlowType
97
+ /** Coarse runtime surface (heuristic — see derivePlatform). */
98
+ platform: Platform
99
+ timestamp: number
100
+ }
101
+
102
+ /** The wallet resolved the op successfully. Closes a handoff. */
103
+ walletSucceeded: {
104
+ handoffId: string
105
+ operation: UWCOperation
106
+ durationMs: number
107
+ }
108
+ /** The user explicitly rejected (4001 / WalletError.type === 'rejected'). Closes a handoff. */
109
+ walletRejected: {
110
+ handoffId: string
111
+ operation: UWCOperation
112
+ durationMs: number
113
+ }
114
+ /** The op failed for any non-rejection reason. Closes a handoff. */
115
+ walletFailed: {
116
+ handoffId: string
117
+ operation: UWCOperation
118
+ durationMs: number
119
+ error: WalletError
120
+ }
121
+ /**
122
+ * Side marker: the deadline passed with the op still pending. OBSERVATIONAL —
123
+ * it does NOT reject, abort, or mutate state; the wallet may still complete and
124
+ * produce the real terminal. Never fires for an op that settled before the
125
+ * deadline. Not itself a terminal.
126
+ */
127
+ walletTimedOut: {
128
+ handoffId: string
129
+ operation: UWCOperation
130
+ durationMs: number
131
+ }
132
+
63
133
  /**
64
134
  * Back-compat catch-all. Fires after every other event above.
65
135
  * New code should prefer the specific events.
@@ -67,7 +137,50 @@ export interface UWCEventMap {
67
137
  change: void
68
138
  }
69
139
 
140
+ /**
141
+ * Events that describe telemetry rather than a state change. `EventManager.emit`
142
+ * skips the legacy `change` cascade for these so high-frequency logging / handoff
143
+ * instrumentation can't trigger React re-renders through `subscribe()`.
144
+ */
145
+ export const TELEMETRY_ONLY_EVENTS: ReadonlySet<UWCEventName> = new Set([
146
+ 'log',
147
+ 'awaitingWallet',
148
+ 'walletSucceeded',
149
+ 'walletRejected',
150
+ 'walletFailed',
151
+ 'walletTimedOut'
152
+ ])
153
+
70
154
  export type UWCEventName = keyof UWCEventMap
71
155
  export type UWCEventListener<K extends UWCEventName> = (
72
156
  data: UWCEventMap[K]
73
157
  ) => void
158
+
159
+ /**
160
+ * Runtime list of every event name. The `Record<UWCEventName, true>` literal
161
+ * forces this to stay exhaustive — adding an event to `UWCEventMap` without
162
+ * listing it here is a compile error — so the observer bridge can never silently
163
+ * drop a new event.
164
+ */
165
+ const EVENT_NAME_FLAGS: Record<UWCEventName, true> = {
166
+ ready: true,
167
+ walletsDetected: true,
168
+ connecting: true,
169
+ connected: true,
170
+ disconnected: true,
171
+ connectionUri: true,
172
+ sessionChanged: true,
173
+ networkSwitching: true,
174
+ networkSwitched: true,
175
+ capabilitiesUpdated: true,
176
+ error: true,
177
+ log: true,
178
+ awaitingWallet: true,
179
+ walletSucceeded: true,
180
+ walletRejected: true,
181
+ walletFailed: true,
182
+ walletTimedOut: true,
183
+ change: true
184
+ }
185
+
186
+ export const UWC_EVENT_NAMES = Object.keys(EVENT_NAME_FLAGS) as UWCEventName[]
package/src/index.ts CHANGED
@@ -9,4 +9,10 @@ export type {
9
9
  UWCEventListener,
10
10
  UWCOperation
11
11
  } from './events'
12
+ export {
13
+ createLogger,
14
+ type ManagedLogger,
15
+ type CreateLoggerOptions
16
+ } from './logger/create-logger'
17
+ export type { UWCObserver, UWCTelemetryEvent } from './observability/telemetry'
12
18
  export { toBaseUnits } from './utils/to-base-units'
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import type { Logger } from '@meshconnect/uwc-types'
3
+ import { createLogger } from './create-logger'
4
+
5
+ function makeSink(): Logger & { calls: Record<string, unknown[][]> } {
6
+ const calls: Record<string, unknown[][]> = {
7
+ debug: [],
8
+ info: [],
9
+ warn: [],
10
+ error: []
11
+ }
12
+ return {
13
+ calls,
14
+ debug: (...a) => calls.debug.push(a),
15
+ info: (...a) => calls.info.push(a),
16
+ warn: (...a) => calls.warn.push(a),
17
+ error: (...a) => calls.error.push(a)
18
+ }
19
+ }
20
+
21
+ describe('createLogger', () => {
22
+ afterEach(() => {
23
+ delete (globalThis as { UWC_DEBUG?: unknown }).UWC_DEBUG
24
+ vi.restoreAllMocks()
25
+ })
26
+
27
+ it('gates the console sink by level (default warn)', () => {
28
+ const sink = makeSink()
29
+ const log = createLogger({ sink })
30
+ log.debug('d')
31
+ log.info('i')
32
+ log.warn('w')
33
+ log.error('e')
34
+ expect(sink.calls.debug).toHaveLength(0)
35
+ expect(sink.calls.info).toHaveLength(0)
36
+ expect(sink.calls.warn).toHaveLength(1)
37
+ expect(sink.calls.error).toHaveLength(1)
38
+ })
39
+
40
+ it('fires onLog for EVERY level, pre-gate (even below the console threshold)', () => {
41
+ const onLog = vi.fn()
42
+ const sink = makeSink()
43
+ const log = createLogger({ level: 'error', sink, onLog })
44
+ log.debug('d', 1)
45
+ log.error('e')
46
+ // onLog sees both; the sink only sees error.
47
+ expect(onLog).toHaveBeenCalledTimes(2)
48
+ expect(onLog).toHaveBeenNthCalledWith(1, 'debug', 'd', [1])
49
+ expect(sink.calls.debug).toHaveLength(0)
50
+ expect(sink.calls.error).toHaveLength(1)
51
+ })
52
+
53
+ it('passes raw (message, ...args) to a custom sink — no ISO prefix', () => {
54
+ const sink = makeSink()
55
+ const log = createLogger({ level: 'debug', sink })
56
+ log.warn('hello', { a: 1 })
57
+ expect(sink.calls.warn[0]).toEqual(['hello', { a: 1 }])
58
+ })
59
+
60
+ it('formats with an ISO/level prefix for the default console sink', () => {
61
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
62
+ const log = createLogger({ level: 'warn' })
63
+ log.warn('boom', 42)
64
+ const firstArg = spy.mock.calls[0]?.[0] as string
65
+ expect(firstArg).toMatch(/WARN: boom$/)
66
+ expect(spy.mock.calls[0]?.[1]).toBe(42)
67
+ })
68
+
69
+ it('setLevel changes the threshold at runtime', () => {
70
+ const sink = makeSink()
71
+ const log = createLogger({ level: 'error', sink })
72
+ log.info('before')
73
+ expect(sink.calls.info).toHaveLength(0)
74
+ log.setLevel('debug')
75
+ log.info('after')
76
+ expect(sink.calls.info).toHaveLength(1)
77
+ expect(log.getLevel()).toBe('debug')
78
+ })
79
+
80
+ it('honours the global UWC_DEBUG override (true ⇒ debug) without recreation', () => {
81
+ const sink = makeSink()
82
+ const log = createLogger({ level: 'error', sink })
83
+ log.debug('hidden')
84
+ expect(sink.calls.debug).toHaveLength(0)
85
+ ;(globalThis as { UWC_DEBUG?: unknown }).UWC_DEBUG = true
86
+ log.debug('shown')
87
+ expect(sink.calls.debug).toHaveLength(1)
88
+ })
89
+
90
+ it('a string UWC_DEBUG override only lowers the threshold, never raises it', () => {
91
+ const sink = makeSink()
92
+ const log = createLogger({ level: 'debug', sink })
93
+ ;(globalThis as { UWC_DEBUG?: unknown }).UWC_DEBUG = 'error'
94
+ // configured=debug, override=error → effective stays debug (min of the two).
95
+ log.debug('still shown')
96
+ expect(sink.calls.debug).toHaveLength(1)
97
+ })
98
+
99
+ it('swallows a throwing onLog so the log call never breaks', () => {
100
+ const sink = makeSink()
101
+ const log = createLogger({
102
+ level: 'warn',
103
+ sink,
104
+ onLog: () => {
105
+ throw new Error('sink boom')
106
+ }
107
+ })
108
+ expect(() => log.warn('ok')).not.toThrow()
109
+ expect(sink.calls.warn).toHaveLength(1)
110
+ })
111
+ })
@@ -0,0 +1,101 @@
1
+ import type { LogLevel, Logger } from '@meshconnect/uwc-types'
2
+
3
+ const LEVEL_WEIGHT: Record<LogLevel, number> = {
4
+ debug: 0,
5
+ info: 1,
6
+ warn: 2,
7
+ error: 3
8
+ }
9
+
10
+ /** The logger returned by `createLogger`, with runtime-tunable level controls. */
11
+ export interface ManagedLogger extends Logger {
12
+ /** Lower/raise the console threshold at runtime (e.g. from a support toggle). */
13
+ setLevel(level: LogLevel): void
14
+ /** The currently configured threshold (ignores the global override). */
15
+ getLevel(): LogLevel
16
+ }
17
+
18
+ export interface CreateLoggerOptions {
19
+ /** Console threshold. Default `'warn'` — UWC stays quiet unless asked. */
20
+ level?: LogLevel | undefined
21
+ /**
22
+ * Pre-gate tap: invoked for EVERY log line regardless of the console threshold.
23
+ * The observer bridge subscribes here so `debug`/`info` reach remote telemetry
24
+ * even when the console is silent. Throws are swallowed — a bad sink must not
25
+ * break a log call.
26
+ */
27
+ onLog?:
28
+ | ((level: LogLevel, message: string, args: unknown[]) => void)
29
+ | undefined
30
+ /**
31
+ * Output sink for gated lines. When provided (e.g. a consumer's own logger), it
32
+ * receives the RAW `(message, ...args)` so it can apply its own formatting. When
33
+ * omitted, the default `console` sink is used with an ISO/level prefix.
34
+ */
35
+ sink?: Logger | undefined
36
+ }
37
+
38
+ /**
39
+ * Resolve the effective console threshold, honouring a runtime global override so
40
+ * an issue can be triaged in a wallet's in-app browser WITHOUT a redeploy:
41
+ *
42
+ * window.UWC_DEBUG = true // surface everything (debug)
43
+ * window.UWC_DEBUG = 'info' // surface info and above
44
+ *
45
+ * The override only ever LOWERS the threshold (shows more) — it can't hide logs a
46
+ * consumer explicitly opted into. Re-read on every call so toggling it mid-session
47
+ * takes effect immediately. SSR/Node-safe via the `globalThis` guard.
48
+ */
49
+ function resolveWeight(configured: LogLevel): number {
50
+ if (typeof globalThis !== 'undefined') {
51
+ const override = (globalThis as { UWC_DEBUG?: unknown }).UWC_DEBUG
52
+ if (override === true) return LEVEL_WEIGHT.debug
53
+ if (typeof override === 'string' && override in LEVEL_WEIGHT) {
54
+ return Math.min(
55
+ LEVEL_WEIGHT[override as LogLevel],
56
+ LEVEL_WEIGHT[configured]
57
+ )
58
+ }
59
+ }
60
+ return LEVEL_WEIGHT[configured]
61
+ }
62
+
63
+ /**
64
+ * Console-backed, level-gated logger. ~zero-dep. Two seams:
65
+ * - `onLog` fires pre-gate (all levels) → drives the observer.
66
+ * - the console sink fires only at/above the threshold.
67
+ */
68
+ export function createLogger(options: CreateLoggerOptions = {}): ManagedLogger {
69
+ let configured: LogLevel = options.level ?? 'warn'
70
+ const customSink = options.sink
71
+
72
+ const emit = (level: LogLevel, message: string, args: unknown[]): void => {
73
+ // Pre-gate tap first — observability must not depend on the console threshold.
74
+ if (options.onLog) {
75
+ try {
76
+ options.onLog(level, message, args)
77
+ } catch {
78
+ // A failing sink must never break the caller's log statement.
79
+ }
80
+ }
81
+ if (LEVEL_WEIGHT[level] < resolveWeight(configured)) return
82
+ if (customSink) {
83
+ customSink[level](message, ...args)
84
+ } else {
85
+ const line = `[${new Date().toISOString()}] ${level.toUpperCase()}: ${message}`
86
+ // eslint-disable-next-line no-console -- this IS the default console sink
87
+ console[level](line, ...args)
88
+ }
89
+ }
90
+
91
+ return {
92
+ debug: (message, ...args) => emit('debug', message, args),
93
+ info: (message, ...args) => emit('info', message, args),
94
+ warn: (message, ...args) => emit('warn', message, args),
95
+ error: (message, ...args) => emit('error', message, args),
96
+ setLevel: level => {
97
+ configured = level
98
+ },
99
+ getLevel: () => configured
100
+ }
101
+ }
@@ -140,5 +140,30 @@ describe('EventManager', () => {
140
140
  eventManager.emit('change', undefined)
141
141
  expect(count).toBe(1)
142
142
  })
143
+
144
+ it('telemetry-only events do NOT cascade to the legacy change channel', () => {
145
+ let changeCount = 0
146
+ eventManager.subscribe(() => {
147
+ changeCount++
148
+ })
149
+ eventManager.emit('log', { level: 'debug', message: 'x', args: [] })
150
+ eventManager.emit('walletTimedOut', {
151
+ handoffId: 'h',
152
+ operation: 'signMessage',
153
+ durationMs: 1
154
+ })
155
+ // Neither should have triggered a re-render via `change`.
156
+ expect(changeCount).toBe(0)
157
+ })
158
+
159
+ it('state-change events still cascade to change alongside telemetry events', () => {
160
+ let changeCount = 0
161
+ eventManager.subscribe(() => {
162
+ changeCount++
163
+ })
164
+ eventManager.emit('log', { level: 'debug', message: 'x', args: [] })
165
+ eventManager.emit('disconnected', undefined)
166
+ expect(changeCount).toBe(1)
167
+ })
143
168
  })
144
169
  })
@@ -1,4 +1,5 @@
1
1
  import type { UWCEventMap, UWCEventName, UWCEventListener } from '../events'
2
+ import { TELEMETRY_ONLY_EVENTS } from '../events'
2
3
 
3
4
  type LegacyListener = () => void
4
5
  type UntypedListener = (data: unknown) => void
@@ -46,7 +47,10 @@ export class EventManager {
46
47
 
47
48
  emit<K extends UWCEventName>(event: K, data: UWCEventMap[K]): void {
48
49
  this.dispatch(event, data)
49
- if (event !== 'change') {
50
+ // Telemetry-only events (logs, handoff instrumentation) describe what
51
+ // happened, not a state change — don't cascade them to `change`, or every
52
+ // log line would trigger a React re-render via legacy `subscribe()`.
53
+ if (event !== 'change' && !TELEMETRY_ONLY_EVENTS.has(event)) {
50
54
  this.dispatch('change', undefined as UWCEventMap['change'])
51
55
  }
52
56
  }
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import type { Session } from '@meshconnect/uwc-types'
3
+ import { EventManager } from '../managers/event-manager'
4
+ import { connectObserver } from './connect-observer'
5
+ import type { UWCTelemetryEvent } from './telemetry'
6
+
7
+ const FIXED_NOW = 1_700_000_000_000
8
+
9
+ describe('connectObserver', () => {
10
+ it('forwards typed events to onEvent as normalized records', () => {
11
+ const em = new EventManager()
12
+ const events: UWCTelemetryEvent[] = []
13
+ connectObserver(
14
+ em,
15
+ { onEvent: e => events.push(e) },
16
+ () => 'sess',
17
+ () => FIXED_NOW
18
+ )
19
+
20
+ em.emit('connecting', { connectionMode: 'injected', walletId: 'metamask' })
21
+
22
+ expect(events).toHaveLength(1)
23
+ expect(events[0]).toMatchObject({
24
+ name: 'connecting',
25
+ operation: 'connect',
26
+ walletId: 'metamask',
27
+ sdkSessionId: 'sess',
28
+ timestamp: FIXED_NOW
29
+ })
30
+ })
31
+
32
+ it('routes log to onLog (scrubbed args), not onEvent', () => {
33
+ const em = new EventManager()
34
+ const onEvent = vi.fn()
35
+ const onLog = vi.fn()
36
+ connectObserver(em, { onEvent, onLog }, () => 'sess')
37
+
38
+ em.emit('log', {
39
+ level: 'info',
40
+ message: 'hi',
41
+ args: [{ secret: 's', id: 1 }]
42
+ })
43
+
44
+ expect(onEvent).not.toHaveBeenCalled()
45
+ expect(onLog).toHaveBeenCalledTimes(1)
46
+ const [level, message, args] = onLog.mock.calls[0]
47
+ expect(level).toBe('info')
48
+ expect(message).toBe('hi')
49
+ expect((args[0] as Record<string, unknown>).secret).toBe('[Redacted]')
50
+ expect((args[0] as Record<string, unknown>).id).toBe(1)
51
+ })
52
+
53
+ it('does not forward the legacy change event', () => {
54
+ const em = new EventManager()
55
+ const onEvent = vi.fn()
56
+ connectObserver(em, { onEvent }, () => 'sess')
57
+ em.emit('change', undefined)
58
+ expect(onEvent).not.toHaveBeenCalled()
59
+ })
60
+
61
+ it('teardown removes every subscription', () => {
62
+ const em = new EventManager()
63
+ const onEvent = vi.fn()
64
+ const teardown = connectObserver(em, { onEvent }, () => 'sess')
65
+ teardown()
66
+ em.emit('connecting', { connectionMode: 'injected', walletId: 'x' })
67
+ expect(onEvent).not.toHaveBeenCalled()
68
+ expect(em.getListenerCount()).toBe(0)
69
+ })
70
+
71
+ it('does not leak the wallet address through a connected event', () => {
72
+ const em = new EventManager()
73
+ const events: UWCTelemetryEvent[] = []
74
+ connectObserver(em, { onEvent: e => events.push(e) }, () => 'sess')
75
+ const session = {
76
+ connectionMode: 'injected',
77
+ activeNetwork: { namespace: 'eip155', id: 'eip155:1' },
78
+ activeWallet: { id: 'metamask' },
79
+ activeAddress: '0xSECRETADDR'
80
+ } as unknown as Session
81
+ em.emit('connected', { session })
82
+ expect(JSON.stringify(events)).not.toContain('0xSECRETADDR')
83
+ })
84
+
85
+ it('a throwing observer never breaks the dispatch chain', () => {
86
+ const em = new EventManager()
87
+ connectObserver(
88
+ em,
89
+ {
90
+ onEvent: () => {
91
+ throw new Error('observer boom')
92
+ }
93
+ },
94
+ () => 'sess'
95
+ )
96
+ expect(() =>
97
+ em.emit('connecting', { connectionMode: 'injected', walletId: 'x' })
98
+ ).not.toThrow()
99
+ })
100
+ })
@@ -0,0 +1,71 @@
1
+ import type { EventManager } from '../managers/event-manager'
2
+ import { UWC_EVENT_NAMES } from '../events'
3
+ import type { UWCEventMap } from '../events'
4
+ import { normalizeEvent, type UWCObserver } from './telemetry'
5
+ import { scrubArgs } from './scrub'
6
+
7
+ /**
8
+ * Bridge the internal event bus to a consumer-supplied `UWCObserver`.
9
+ *
10
+ * Subscribes to every `UWCEventMap` event, normalizes each to a PII-safe
11
+ * `UWCTelemetryEvent`, and forwards it to `observer.onEvent`. The `log` event is
12
+ * routed to `observer.onLog` instead (with scrubbed args) — it's the logging
13
+ * channel, not a telemetry record. `change` is skipped: it's the legacy
14
+ * re-render cascade and carries no payload.
15
+ *
16
+ * Returns a teardown function that removes every subscription. The caller MUST
17
+ * invoke it on connector teardown — leaving these attached is the same listener-
18
+ * leak class as the historical `BridgeParent.destroy()` zombie bug.
19
+ *
20
+ * @param getCorrelationId - resolves the correlation id stamped on every event,
21
+ * called per-event so a consumer-supplied id that arrives/changes after
22
+ * construction is honoured. Must not throw (the caller pre-wraps it).
23
+ * @param now - clock injectable for deterministic tests; defaults to `Date.now`.
24
+ */
25
+ export function connectObserver(
26
+ eventManager: EventManager,
27
+ observer: UWCObserver,
28
+ getCorrelationId: () => string,
29
+ now: () => number = Date.now
30
+ ): () => void {
31
+ const unsubscribers: Array<() => void> = []
32
+
33
+ for (const name of UWC_EVENT_NAMES) {
34
+ if (name === 'change') continue
35
+
36
+ if (name === 'log') {
37
+ unsubscribers.push(
38
+ eventManager.on('log', data => {
39
+ if (!observer.onLog) return
40
+ const { level, message, args } = data as UWCEventMap['log']
41
+ try {
42
+ observer.onLog(level, message, scrubArgs(args))
43
+ } catch {
44
+ // A throwing observer must not break the dispatch chain.
45
+ }
46
+ })
47
+ )
48
+ continue
49
+ }
50
+
51
+ unsubscribers.push(
52
+ eventManager.on(name, data => {
53
+ if (!observer.onEvent) return
54
+ try {
55
+ // Normalize OUTSIDE the EventManager's own try/catch (which guards the
56
+ // listener, not the work the listener does), then hand off.
57
+ observer.onEvent(
58
+ normalizeEvent(name, data, getCorrelationId(), now())
59
+ )
60
+ } catch {
61
+ // Same: never let a bad observer break other listeners.
62
+ }
63
+ })
64
+ )
65
+ }
66
+
67
+ return () => {
68
+ for (const unsubscribe of unsubscribers) unsubscribe()
69
+ unsubscribers.length = 0
70
+ }
71
+ }