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

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 +66 -211
  2. package/dist/events.d.ts +2 -69
  3. package/dist/events.d.ts.map +1 -1
  4. package/dist/events.js +1 -40
  5. package/dist/events.js.map +1 -1
  6. package/dist/index.d.ts +0 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +0 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/managers/event-manager.d.ts.map +1 -1
  11. package/dist/managers/event-manager.js +1 -5
  12. package/dist/managers/event-manager.js.map +1 -1
  13. package/dist/services/connection-service.d.ts +1 -2
  14. package/dist/services/connection-service.d.ts.map +1 -1
  15. package/dist/services/connection-service.js +15 -35
  16. package/dist/services/connection-service.js.map +1 -1
  17. package/dist/services/network-switch-service.d.ts +1 -2
  18. package/dist/services/network-switch-service.d.ts.map +1 -1
  19. package/dist/services/network-switch-service.js +15 -33
  20. package/dist/services/network-switch-service.js.map +1 -1
  21. package/dist/universal-wallet-connector.d.ts +2 -51
  22. package/dist/universal-wallet-connector.d.ts.map +1 -1
  23. package/dist/universal-wallet-connector.js +61 -163
  24. package/dist/universal-wallet-connector.js.map +1 -1
  25. package/package.json +5 -5
  26. package/src/events.ts +1 -114
  27. package/src/index.ts +0 -6
  28. package/src/managers/event-manager.test.ts +0 -25
  29. package/src/managers/event-manager.ts +1 -5
  30. package/src/services/connection-service.test.ts +1 -66
  31. package/src/services/connection-service.ts +32 -54
  32. package/src/services/network-switch-service.test.ts +1 -51
  33. package/src/services/network-switch-service.ts +23 -43
  34. package/src/universal-wallet-connector.ts +71 -244
  35. package/dist/logger/create-logger.d.ts +0 -32
  36. package/dist/logger/create-logger.d.ts.map +0 -1
  37. package/dist/logger/create-logger.js +0 -69
  38. package/dist/logger/create-logger.js.map +0 -1
  39. package/dist/observability/connect-observer.d.ts +0 -22
  40. package/dist/observability/connect-observer.d.ts.map +0 -1
  41. package/dist/observability/connect-observer.js +0 -60
  42. package/dist/observability/connect-observer.js.map +0 -1
  43. package/dist/observability/instrument-handoff.d.ts +0 -39
  44. package/dist/observability/instrument-handoff.d.ts.map +0 -1
  45. package/dist/observability/instrument-handoff.js +0 -86
  46. package/dist/observability/instrument-handoff.js.map +0 -1
  47. package/dist/observability/scrub.d.ts +0 -30
  48. package/dist/observability/scrub.d.ts.map +0 -1
  49. package/dist/observability/scrub.js +0 -135
  50. package/dist/observability/scrub.js.map +0 -1
  51. package/dist/observability/telemetry.d.ts +0 -77
  52. package/dist/observability/telemetry.d.ts.map +0 -1
  53. package/dist/observability/telemetry.js +0 -138
  54. package/dist/observability/telemetry.js.map +0 -1
  55. package/dist/utils/id.d.ts +0 -2
  56. package/dist/utils/id.d.ts.map +0 -1
  57. package/dist/utils/id.js +0 -41
  58. package/dist/utils/id.js.map +0 -1
  59. package/dist/utils/to-wallet-error.d.ts +0 -16
  60. package/dist/utils/to-wallet-error.d.ts.map +0 -1
  61. package/dist/utils/to-wallet-error.js +0 -33
  62. package/dist/utils/to-wallet-error.js.map +0 -1
  63. package/src/logger/create-logger.test.ts +0 -111
  64. package/src/logger/create-logger.ts +0 -101
  65. package/src/observability/connect-observer.test.ts +0 -100
  66. package/src/observability/connect-observer.ts +0 -71
  67. package/src/observability/instrument-handoff.test.ts +0 -150
  68. package/src/observability/instrument-handoff.ts +0 -122
  69. package/src/observability/scrub.test.ts +0 -109
  70. package/src/observability/scrub.ts +0 -142
  71. package/src/observability/telemetry.test.ts +0 -134
  72. package/src/observability/telemetry.ts +0 -211
  73. package/src/universal-wallet-connector.observability.test.ts +0 -265
  74. package/src/utils/id.test.ts +0 -15
  75. package/src/utils/id.ts +0 -48
  76. package/src/utils/to-wallet-error.ts +0 -36
@@ -1,109 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { scrubValue, scrubMessage, scrubArgs } from './scrub'
3
-
4
- describe('scrub', () => {
5
- it('redacts sensitive object keys (substring match, case-insensitive)', () => {
6
- const out = scrubValue({
7
- walletPrivateKey: '0xabc',
8
- authToken: 'jwt',
9
- 'x-api-key': 'k',
10
- mnemonic: 'a b c',
11
- signature: '0xsig',
12
- message: 'sign me',
13
- chainId: 'eip155:1'
14
- }) as Record<string, unknown>
15
- expect(out.walletPrivateKey).toBe('[Redacted]')
16
- expect(out.authToken).toBe('[Redacted]')
17
- expect(out['x-api-key']).toBe('[Redacted]')
18
- expect(out.mnemonic).toBe('[Redacted]')
19
- expect(out.signature).toBe('[Redacted]')
20
- expect(out.message).toBe('[Redacted]')
21
- // Non-sensitive keys pass through.
22
- expect(out.chainId).toBe('eip155:1')
23
- })
24
-
25
- it('redacts secrets embedded in free-form strings (hex blobs, k/v params, bearer)', () => {
26
- // EVM address (40 nibbles) and a longer signature/key blob.
27
- expect(
28
- scrubMessage(
29
- 'insufficient funds for 0x1234567890abcdef1234567890abcdef12345678'
30
- )
31
- ).toBe('insufficient funds for 0x[redacted-hex]')
32
- expect(
33
- scrubMessage('relay https://r.io/?token=abc123secret&chain=1 failed')
34
- ).toContain('token=[redacted]')
35
- expect(scrubMessage('Authorization: Bearer eyJabc.def-ghi')).toContain(
36
- 'Bearer [redacted]'
37
- )
38
- })
39
-
40
- it('redacts embedded secrets inside object string values too', () => {
41
- const out = scrubValue({
42
- note: 'sent to 0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'
43
- }) as Record<string, unknown>
44
- expect(out.note).toBe('sent to 0x[redacted-hex]')
45
- })
46
-
47
- it('caps long strings', () => {
48
- const long = 'x'.repeat(600)
49
- const out = scrubMessage(long)
50
- expect(out.length).toBeLessThan(600)
51
- expect(out.endsWith('…[truncated]')).toBe(true)
52
- })
53
-
54
- it('redacts a hex blob before the length cap can slice it (no surviving tail)', () => {
55
- // The secret must be redacted whole BEFORE truncation — never sliced so a
56
- // partial address survives the cut. The address sits past the 500-char cap.
57
- const addr = '0x' + 'a'.repeat(40)
58
- const out = scrubMessage('y'.repeat(600) + addr)
59
- expect(out).not.toContain(addr)
60
- // And when the placeholder fits, it's present (redaction actually ran).
61
- const out2 = scrubMessage('y'.repeat(100) + addr + 'z'.repeat(600))
62
- expect(out2).not.toContain(addr)
63
- expect(out2).toContain('0x[redacted-hex]')
64
- })
65
-
66
- it('uses a null prototype so __proto__ keys cannot pollute Object.prototype', () => {
67
- const malicious = JSON.parse('{"__proto__": {"polluted": true}}')
68
- const out = scrubValue(malicious) as Record<string, unknown>
69
- expect(Object.getPrototypeOf(out)).toBeNull()
70
- expect(({} as Record<string, unknown>).polluted).toBeUndefined()
71
- })
72
-
73
- it('renders bigint, function and symbol inertly', () => {
74
- expect(scrubValue(10n)).toBe('10')
75
- expect(scrubValue(() => 1)).toBe('[function]')
76
- expect(scrubValue(Symbol('s'))).toBe('[symbol]')
77
- })
78
-
79
- it('reduces Error instances to name + message (no stack)', () => {
80
- const out = scrubValue(new Error('kaboom')) as Record<string, unknown>
81
- expect(out).toEqual({ name: 'Error', message: 'kaboom' })
82
- expect(out.stack).toBeUndefined()
83
- })
84
-
85
- it('stops recursing past max depth', () => {
86
- const deep = { a: { b: { c: { d: { e: { f: 1 } } } } } }
87
- const out = scrubValue(deep) as Record<string, unknown>
88
- // 5 levels down we hit the cap.
89
- const a = out.a as Record<string, unknown>
90
- const b = a.b as Record<string, unknown>
91
- const c = b.c as Record<string, unknown>
92
- const d = c.d as Record<string, unknown>
93
- expect(d.e).toBe('[Truncated: max depth]')
94
- })
95
-
96
- it('scrubArgs maps every arg', () => {
97
- const out = scrubArgs(['ok', { secret: 's', id: 1 }])
98
- expect(out[0]).toBe('ok')
99
- expect((out[1] as Record<string, unknown>).secret).toBe('[Redacted]')
100
- expect((out[1] as Record<string, unknown>).id).toBe(1)
101
- })
102
-
103
- it('passes null/undefined/primitives through', () => {
104
- expect(scrubValue(null)).toBeNull()
105
- expect(scrubValue(undefined)).toBeUndefined()
106
- expect(scrubValue(true)).toBe(true)
107
- expect(scrubValue(5)).toBe(5)
108
- })
109
- })
@@ -1,142 +0,0 @@
1
- /**
2
- * PII / secret scrubber for data leaving UWC through the observer seam.
3
- *
4
- * Security-first: the normalized `UWCTelemetryEvent` excludes signing payloads
5
- * and addresses by construction — its STRUCTURED fields have no slot for them
6
- * (§ telemetry.ts). This scrubber is the second line of defence for the two
7
- * surfaces that CAN still carry free-form data on the observer path: the
8
- * `WalletError.message` forwarded to `observer.onEvent`, and the `args` forwarded
9
- * to `observer.onLog`. (The console / custom-logger sink receives raw data — it's
10
- * local, not egress.)
11
- *
12
- * Two detection layers:
13
- * - KEY-based redaction of sensitive object keys — substring match (not regex),
14
- * avoiding the ReDoS class that Wiz flags on user-input regexes.
15
- * - CONTENT-based redaction of secrets embedded in free-form strings (hex blobs,
16
- * secret k/v params, bearer tokens) — via linear, single-quantifier regexes
17
- * (see SECRET_CONTENT_PATTERNS) that carry no catastrophic-backtracking risk.
18
- */
19
-
20
- /**
21
- * Separator-free, lower-cased fragments that mark a value as sensitive. Keys are
22
- * normalized (separators stripped) before matching, so `walletPrivateKey`,
23
- * `private_key`, `x-api-key`, and `authToken` all hit the same short list. This
24
- * over-redacts by design (a `security`-first scrubber prefers a false redaction
25
- * to a leak).
26
- */
27
- const SENSITIVE_KEY_FRAGMENTS = [
28
- 'seed',
29
- 'mnemonic',
30
- 'privatekey',
31
- 'secret',
32
- 'password',
33
- 'passphrase',
34
- 'signature',
35
- 'message',
36
- 'payload',
37
- 'token',
38
- 'apikey',
39
- 'authorization',
40
- 'auth'
41
- ]
42
-
43
- const MAX_STRING_LENGTH = 500
44
- const MAX_DEPTH = 4
45
- const REDACTED = '[Redacted]'
46
-
47
- function isSensitiveKey(key: string): boolean {
48
- // Strip separators so `private_key` / `x-api-key` normalize to the bare form.
49
- const normalized = key.toLowerCase().split('-').join('').split('_').join('')
50
- return SENSITIVE_KEY_FRAGMENTS.some(fragment => normalized.includes(fragment))
51
- }
52
-
53
- // Content patterns for secrets embedded in FREE-FORM strings (error messages,
54
- // log args) where key-based redaction can't reach. All are linear, anchored,
55
- // single-quantifier expressions — no nested/overlapping quantifiers — so they
56
- // carry no catastrophic-backtracking (ReDoS) risk on attacker-controlled input.
57
- const SECRET_CONTENT_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
58
- // 0x-prefixed hex blobs: EVM addresses (40 nibbles) and longer
59
- // signatures/keys/hashes/calldata (64+). Over-redacts public hashes by design.
60
- [/0x[a-fA-F0-9]{40,}/g, '0x[redacted-hex]'],
61
- // `secret=…` / `token=…` / `key=…` query-string or k/v fragments.
62
- [
63
- /\b(token|key|secret|auth|sig|signature|password|passphrase|mnemonic|seed|apikey)=[^\s&"']+/gi,
64
- '$1=[redacted]'
65
- ],
66
- // `Bearer <opaque>` / `Basic <opaque>` authorization values.
67
- [/\b(bearer|basic)\s+[A-Za-z0-9._~+/-]+=*/gi, '$1 [redacted]']
68
- ]
69
-
70
- /**
71
- * Redact secrets that live INSIDE a free-form string. Best-effort and
72
- * deliberately conservative toward over-redaction: a wallet error string can
73
- * embed an address, a reverted-tx calldata blob, or a URL with a token, none of
74
- * which key-based redaction sees. Two documented best-effort gaps, both left
75
- * unmatched to avoid destroying legitimate data:
76
- * - Base58 (Solana) / TON `EQ…` addresses — indistinguishable from ordinary
77
- * words without false positives.
78
- * - BARE (non-`0x`) hex blobs — a raw 64-char hex string is far more often a
79
- * legitimate id/hash than a secret, so only `0x`-prefixed hex is redacted.
80
- * Structured telemetry fields never carry addresses regardless (see telemetry.ts);
81
- * this pass only hardens the free-form `error.message` / log-arg surface.
82
- */
83
- function redactSecretsInString(input: string): string {
84
- let out = input
85
- for (const [pattern, replacement] of SECRET_CONTENT_PATTERNS) {
86
- out = out.replace(pattern, replacement)
87
- }
88
- return out
89
- }
90
-
91
- /**
92
- * Recursively copy `value`, redacting sensitive object-keys, capping long
93
- * strings, and rendering non-serializable values inert. The returned objects use
94
- * a null prototype so a malicious `__proto__`/`constructor` key in attacker-shaped
95
- * input can't pollute `Object.prototype` downstream.
96
- */
97
- export function scrubValue(value: unknown, depth = 0): unknown {
98
- if (depth > MAX_DEPTH) return '[Truncated: max depth]'
99
-
100
- if (value === null || value === undefined) return value
101
-
102
- const type = typeof value
103
- if (type === 'string') {
104
- // Redact embedded secrets BEFORE capping, so the cap can't slice a pattern
105
- // in half and leak its tail.
106
- const str = redactSecretsInString(value as string)
107
- return str.length > MAX_STRING_LENGTH
108
- ? str.slice(0, MAX_STRING_LENGTH) + '…[truncated]'
109
- : str
110
- }
111
- if (type === 'number' || type === 'boolean') return value
112
- if (type === 'bigint') return (value as bigint).toString()
113
- if (type === 'function' || type === 'symbol') return `[${type}]`
114
-
115
- if (Array.isArray(value)) {
116
- return value.map(item => scrubValue(item, depth + 1))
117
- }
118
-
119
- if (type === 'object') {
120
- // Error instances carry a useful message but also a stack we don't want.
121
- if (value instanceof Error) {
122
- return { name: value.name, message: scrubValue(value.message, depth + 1) }
123
- }
124
- const out: Record<string, unknown> = Object.create(null)
125
- for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
126
- out[key] = isSensitiveKey(key) ? REDACTED : scrubValue(val, depth + 1)
127
- }
128
- return out
129
- }
130
-
131
- return '[Unserializable]'
132
- }
133
-
134
- /** Scrub a free-form error message (string in → capped string out). */
135
- export function scrubMessage(message: string): string {
136
- return scrubValue(message) as string
137
- }
138
-
139
- /** Scrub a variadic `args` array before it reaches `observer.onLog`. */
140
- export function scrubArgs(args: unknown[]): unknown[] {
141
- return args.map(arg => scrubValue(arg))
142
- }
@@ -1,134 +0,0 @@
1
- import { describe, it, expect, afterEach, vi } from 'vitest'
2
- import type { Session } from '@meshconnect/uwc-types'
3
- import {
4
- normalizeEvent,
5
- deriveWalletFlowType,
6
- derivePlatform
7
- } from './telemetry'
8
-
9
- describe('deriveWalletFlowType', () => {
10
- it('maps injected to extension on desktop, in-wallet-browser on mobile', () => {
11
- expect(deriveWalletFlowType('injected', 'desktop')).toBe(
12
- 'browser_extension_flow'
13
- )
14
- expect(deriveWalletFlowType('injected', 'mobile')).toBe(
15
- 'in_wallet_browser_flow'
16
- )
17
- expect(deriveWalletFlowType('injected', 'webview')).toBe(
18
- 'in_wallet_browser_flow'
19
- )
20
- })
21
-
22
- it('maps walletConnect to QR on desktop, deeplink on mobile', () => {
23
- expect(deriveWalletFlowType('walletConnect', 'desktop')).toBe(
24
- 'wallet_connect_qr_flow'
25
- )
26
- expect(deriveWalletFlowType('walletConnect', 'mobile')).toBe(
27
- 'wallet_connect_deep_link_flow'
28
- )
29
- })
30
-
31
- it('maps tonConnect to QR on desktop, deeplink on mobile', () => {
32
- expect(deriveWalletFlowType('tonConnect', 'desktop')).toBe(
33
- 'ton_connect_qr_flow'
34
- )
35
- expect(deriveWalletFlowType('tonConnect', 'mobile')).toBe(
36
- 'ton_connect_deep_link_flow'
37
- )
38
- })
39
- })
40
-
41
- describe('derivePlatform', () => {
42
- afterEach(() => vi.unstubAllGlobals())
43
-
44
- it('returns desktop for a desktop UA', () => {
45
- vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh)' })
46
- expect(derivePlatform()).toBe('desktop')
47
- })
48
-
49
- it('returns mobile for an iOS Safari UA', () => {
50
- vi.stubGlobal('navigator', {
51
- userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Safari/604.1'
52
- })
53
- expect(derivePlatform()).toBe('mobile')
54
- })
55
-
56
- it('returns webview for an Android in-app browser UA (; wv)', () => {
57
- vi.stubGlobal('navigator', {
58
- userAgent: 'Mozilla/5.0 (Linux; Android 13; wv) Chrome/120'
59
- })
60
- expect(derivePlatform()).toBe('webview')
61
- })
62
- })
63
-
64
- describe('normalizeEvent', () => {
65
- const SDK_SESSION = 'sess-1'
66
- const NOW = 1_700_000_000_000
67
-
68
- it('reduces a `connected` session to facets and never leaks the address', () => {
69
- const session = {
70
- connectionMode: 'injected',
71
- activeNetwork: { namespace: 'eip155', id: 'eip155:1', name: 'Ethereum' },
72
- activeWallet: { id: 'metamask', name: 'MetaMask' },
73
- activeAddress: '0xDEADBEEFcafe1234567890',
74
- publicKey: 'pubkey-should-not-leak'
75
- } as unknown as Session
76
-
77
- const out = normalizeEvent('connected', { session }, SDK_SESSION, NOW)
78
-
79
- expect(out.namespace).toBe('eip155')
80
- expect(out.chainId).toBe('eip155:1')
81
- expect(out.walletId).toBe('metamask')
82
- expect(out.sdkSessionId).toBe(SDK_SESSION)
83
- expect(out.timestamp).toBe(NOW)
84
- // The address / pubkey must be structurally absent from the record.
85
- const serialized = JSON.stringify(out)
86
- expect(serialized).not.toContain('0xDEADBEEF')
87
- expect(serialized).not.toContain('pubkey-should-not-leak')
88
- })
89
-
90
- it('scrubs the error message on `error` events', () => {
91
- const out = normalizeEvent(
92
- 'error',
93
- {
94
- error: { type: 'unknown', message: 'x'.repeat(800) },
95
- operation: 'signMessage'
96
- },
97
- SDK_SESSION,
98
- NOW
99
- )
100
- expect(out.operation).toBe('signMessage')
101
- expect(out.error?.message.endsWith('…[truncated]')).toBe(true)
102
- })
103
-
104
- it('passes handoff facets through for awaitingWallet', () => {
105
- const out = normalizeEvent(
106
- 'awaitingWallet',
107
- {
108
- operation: 'sendTransaction',
109
- connectionMode: 'walletConnect',
110
- walletId: 'rainbow',
111
- namespace: 'eip155',
112
- chainId: 'eip155:8453',
113
- handoffId: 'h-1',
114
- walletFlowType: 'wallet_connect_deep_link_flow',
115
- platform: 'mobile',
116
- timestamp: NOW
117
- },
118
- SDK_SESSION,
119
- NOW
120
- )
121
- expect(out.handoffId).toBe('h-1')
122
- expect(out.walletFlowType).toBe('wallet_connect_deep_link_flow')
123
- expect(out.platform).toBe('mobile')
124
- })
125
-
126
- it('collapses noisy/unknown events to name only', () => {
127
- const out = normalizeEvent('ready', undefined, SDK_SESSION, NOW)
128
- expect(out).toEqual({
129
- name: 'ready',
130
- sdkSessionId: SDK_SESSION,
131
- timestamp: NOW
132
- })
133
- })
134
- })
@@ -1,211 +0,0 @@
1
- import type {
2
- ConnectionMode,
3
- WalletError,
4
- WalletFlowType,
5
- Platform,
6
- LogLevel,
7
- Namespace,
8
- NetworkId
9
- } from '@meshconnect/uwc-types'
10
- import type { UWCEventName, UWCEventMap, UWCOperation } from '../events'
11
- import { scrubMessage } from './scrub'
12
-
13
- /**
14
- * A normalized, PII-safe telemetry record derived from a `UWCEventMap` event.
15
- *
16
- * This is the ONLY shape that reaches `observer.onEvent`. It is the PII boundary:
17
- * it has no field for a signing payload, signature, raw transaction, or wallet
18
- * address — leaking those is structurally impossible, not merely discouraged.
19
- */
20
- export interface UWCTelemetryEvent {
21
- /** The originating event name. */
22
- name: UWCEventName
23
- /** Per-connector-instance correlation id (stamped on every event). */
24
- sdkSessionId: string
25
- /** ms since epoch when the record was produced. */
26
- timestamp: number
27
- /** Wallet-boundary op, when the event relates to one. */
28
- operation?: UWCOperation | undefined
29
- connectionMode?: ConnectionMode | undefined
30
- /** CAIP namespace (eip155 | solana | tron | tvm …) — never an address. */
31
- namespace?: Namespace | undefined
32
- /** CAIP chain id (e.g. `eip155:1`) — never an address. */
33
- chainId?: NetworkId | undefined
34
- /** Catalog wallet id — never an address. */
35
- walletId?: string | undefined
36
- /** Correlates a handoff start with its terminal. */
37
- handoffId?: string | undefined
38
- durationMs?: number | undefined
39
- walletFlowType?: WalletFlowType | undefined
40
- platform?: Platform | undefined
41
- /** Present on failure events. `message` is scrubbed before egress. */
42
- error?: WalletError | undefined
43
- /** Log level, present only for `name === 'log'`. */
44
- level?: LogLevel | undefined
45
- }
46
-
47
- /**
48
- * Vendor-agnostic telemetry sink. Consumers implement; UWC ships no vendor SDK.
49
- * Both methods are optional and called best-effort (throws are swallowed upstream).
50
- *
51
- * CONTRACT — both methods are invoked **synchronously on the wallet-operation
52
- * path**. A terminal event (e.g. `walletSucceeded`) fires between the wallet
53
- * returning a result and that result reaching the caller, so a slow handler adds
54
- * latency to connect / sign / sendTransaction. Keep handlers **non-blocking**:
55
- * hand off to a queue-backed sink (Datadog `addAction`, Segment `track`, etc. are
56
- * already non-blocking) or defer heavy work yourself — e.g.
57
- * `onEvent: e => queueMicrotask(() => doHeavyWork(e))`. Do not perform synchronous
58
- * network/IO or expensive serialization inline.
59
- */
60
- export interface UWCObserver {
61
- /** A normalized, PII-safe event. Forward to Datadog / Amplitude / Segment / … (non-blocking). */
62
- onEvent?(event: UWCTelemetryEvent): void
63
- /** A log line (all levels). `args` is already scrubbed. Keep non-blocking. */
64
- onLog?(level: LogLevel, message: string, args: unknown[]): void
65
- }
66
-
67
- /**
68
- * Best-effort coarse runtime surface. HEURISTIC — user agents lie and in-app
69
- * browsers are hard to detect; treat `webview` as a hint, not a guarantee.
70
- * Never returns the raw UA (PII / high-cardinality).
71
- */
72
- export function derivePlatform(): Platform {
73
- if (typeof navigator === 'undefined' || !navigator.userAgent) return 'unknown'
74
- const ua = navigator.userAgent
75
- const isIOS = /iPhone|iPad|iPod/i.test(ua)
76
- const isAndroid = /Android/i.test(ua)
77
- // Android in-app browsers tag the UA with `; wv`; iOS WKWebViews lack `Safari`.
78
- const isWebview =
79
- (isAndroid && /; wv\)/i.test(ua)) || (isIOS && !/Safari/i.test(ua))
80
- if (isWebview) return 'webview'
81
- if (isIOS || isAndroid) return 'mobile'
82
- return 'desktop'
83
- }
84
-
85
- /**
86
- * Derive the flow taxonomy from connection mode + surface. HEURISTIC — UWC does
87
- * not always know whether a WalletConnect handoff used a deeplink vs a QR scan;
88
- * we infer from the platform (mobile ⇒ deeplink, desktop ⇒ QR), matching link-v2's
89
- * historical intent. Consumers with better context may override downstream.
90
- */
91
- export function deriveWalletFlowType(
92
- connectionMode: ConnectionMode,
93
- platform: Platform
94
- ): WalletFlowType {
95
- const isDesktop = platform === 'desktop'
96
- switch (connectionMode) {
97
- case 'injected':
98
- // Injected on a phone almost always means we're inside a wallet's browser.
99
- return isDesktop ? 'browser_extension_flow' : 'in_wallet_browser_flow'
100
- case 'walletConnect':
101
- return isDesktop
102
- ? 'wallet_connect_qr_flow'
103
- : 'wallet_connect_deep_link_flow'
104
- case 'tonConnect':
105
- return isDesktop ? 'ton_connect_qr_flow' : 'ton_connect_deep_link_flow'
106
- default:
107
- return 'unknown'
108
- }
109
- }
110
-
111
- /**
112
- * Map a raw `UWCEventMap` event to its PII-safe `UWCTelemetryEvent`.
113
- *
114
- * Per-event allow-listing: only known-safe fields are copied. Payloads carrying a
115
- * `Session`/`Network` (which include the wallet address) are reduced to
116
- * `namespace` + `chainId` + `walletId`. Unknown/noisy payloads collapse to just
117
- * the name. `error.message` is scrubbed.
118
- */
119
- export function normalizeEvent<K extends UWCEventName>(
120
- name: K,
121
- data: UWCEventMap[K],
122
- sdkSessionId: string,
123
- now: number
124
- ): UWCTelemetryEvent {
125
- const base: UWCTelemetryEvent = { name, sdkSessionId, timestamp: now }
126
-
127
- switch (name) {
128
- case 'connecting': {
129
- const d = data as UWCEventMap['connecting']
130
- return {
131
- ...base,
132
- operation: 'connect',
133
- connectionMode: d.connectionMode,
134
- walletId: d.walletId
135
- }
136
- }
137
- case 'connected':
138
- case 'sessionChanged': {
139
- const d = data as UWCEventMap['connected']
140
- const session = d.session
141
- return {
142
- ...base,
143
- connectionMode: session.connectionMode ?? undefined,
144
- namespace: session.activeNetwork?.namespace,
145
- chainId: session.activeNetwork?.id,
146
- walletId: session.activeWallet?.id
147
- }
148
- }
149
- case 'networkSwitched': {
150
- const d = data as UWCEventMap['networkSwitched']
151
- return {
152
- ...base,
153
- operation: 'switchNetwork',
154
- namespace: d.network.namespace,
155
- chainId: d.network.id
156
- }
157
- }
158
- case 'connectionUri': {
159
- // Carry the mode but NEVER the URI itself (a relay/pairing string).
160
- const d = data as UWCEventMap['connectionUri']
161
- return { ...base, connectionMode: d.connectionMode }
162
- }
163
- case 'error': {
164
- const d = data as UWCEventMap['error']
165
- return {
166
- ...base,
167
- operation: d.operation,
168
- error: { type: d.error.type, message: scrubMessage(d.error.message) }
169
- }
170
- }
171
- case 'awaitingWallet': {
172
- const d = data as UWCEventMap['awaitingWallet']
173
- return {
174
- ...base,
175
- operation: d.operation,
176
- connectionMode: d.connectionMode,
177
- walletId: d.walletId,
178
- namespace: d.namespace,
179
- chainId: d.chainId,
180
- handoffId: d.handoffId,
181
- walletFlowType: d.walletFlowType,
182
- platform: d.platform
183
- }
184
- }
185
- case 'walletSucceeded':
186
- case 'walletRejected':
187
- case 'walletTimedOut': {
188
- const d = data as UWCEventMap['walletSucceeded']
189
- return {
190
- ...base,
191
- operation: d.operation,
192
- handoffId: d.handoffId,
193
- durationMs: d.durationMs
194
- }
195
- }
196
- case 'walletFailed': {
197
- const d = data as UWCEventMap['walletFailed']
198
- return {
199
- ...base,
200
- operation: d.operation,
201
- handoffId: d.handoffId,
202
- durationMs: d.durationMs,
203
- error: { type: d.error.type, message: scrubMessage(d.error.message) }
204
- }
205
- }
206
- default:
207
- // ready / walletsDetected / disconnected / networkSwitching /
208
- // capabilitiesUpdated / change / log — name only (log goes via onLog).
209
- return base
210
- }
211
- }