@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.
- package/README.md +211 -66
- package/dist/events.d.ts +69 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +40 -1
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/logger/create-logger.d.ts +32 -0
- package/dist/logger/create-logger.d.ts.map +1 -0
- package/dist/logger/create-logger.js +69 -0
- package/dist/logger/create-logger.js.map +1 -0
- package/dist/managers/event-manager.d.ts.map +1 -1
- package/dist/managers/event-manager.js +5 -1
- package/dist/managers/event-manager.js.map +1 -1
- package/dist/observability/connect-observer.d.ts +22 -0
- package/dist/observability/connect-observer.d.ts.map +1 -0
- package/dist/observability/connect-observer.js +60 -0
- package/dist/observability/connect-observer.js.map +1 -0
- package/dist/observability/instrument-handoff.d.ts +39 -0
- package/dist/observability/instrument-handoff.d.ts.map +1 -0
- package/dist/observability/instrument-handoff.js +86 -0
- package/dist/observability/instrument-handoff.js.map +1 -0
- package/dist/observability/scrub.d.ts +30 -0
- package/dist/observability/scrub.d.ts.map +1 -0
- package/dist/observability/scrub.js +135 -0
- package/dist/observability/scrub.js.map +1 -0
- package/dist/observability/telemetry.d.ts +77 -0
- package/dist/observability/telemetry.d.ts.map +1 -0
- package/dist/observability/telemetry.js +138 -0
- package/dist/observability/telemetry.js.map +1 -0
- package/dist/services/connection-service.d.ts +2 -1
- package/dist/services/connection-service.d.ts.map +1 -1
- package/dist/services/connection-service.js +35 -15
- package/dist/services/connection-service.js.map +1 -1
- package/dist/services/network-switch-service.d.ts +2 -1
- package/dist/services/network-switch-service.d.ts.map +1 -1
- package/dist/services/network-switch-service.js +33 -15
- package/dist/services/network-switch-service.js.map +1 -1
- package/dist/universal-wallet-connector.d.ts +51 -2
- package/dist/universal-wallet-connector.d.ts.map +1 -1
- package/dist/universal-wallet-connector.js +163 -61
- package/dist/universal-wallet-connector.js.map +1 -1
- package/dist/utils/id.d.ts +2 -0
- package/dist/utils/id.d.ts.map +1 -0
- package/dist/utils/id.js +41 -0
- package/dist/utils/id.js.map +1 -0
- package/dist/utils/to-wallet-error.d.ts +16 -0
- package/dist/utils/to-wallet-error.d.ts.map +1 -0
- package/dist/utils/to-wallet-error.js +33 -0
- package/dist/utils/to-wallet-error.js.map +1 -0
- package/package.json +5 -5
- package/src/events.ts +114 -1
- package/src/index.ts +6 -0
- package/src/logger/create-logger.test.ts +111 -0
- package/src/logger/create-logger.ts +101 -0
- package/src/managers/event-manager.test.ts +25 -0
- package/src/managers/event-manager.ts +5 -1
- package/src/observability/connect-observer.test.ts +100 -0
- package/src/observability/connect-observer.ts +71 -0
- package/src/observability/instrument-handoff.test.ts +150 -0
- package/src/observability/instrument-handoff.ts +122 -0
- package/src/observability/scrub.test.ts +109 -0
- package/src/observability/scrub.ts +142 -0
- package/src/observability/telemetry.test.ts +134 -0
- package/src/observability/telemetry.ts +211 -0
- package/src/services/connection-service.test.ts +66 -1
- package/src/services/connection-service.ts +54 -32
- package/src/services/network-switch-service.test.ts +51 -1
- package/src/services/network-switch-service.ts +43 -23
- package/src/universal-wallet-connector.observability.test.ts +265 -0
- package/src/universal-wallet-connector.ts +244 -71
- package/src/utils/id.test.ts +15 -0
- package/src/utils/id.ts +48 -0
- package/src/utils/to-wallet-error.ts +36 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { WalletConnectorError } from '@meshconnect/uwc-types'
|
|
3
|
+
import { EventManager } from '../managers/event-manager'
|
|
4
|
+
import { instrumentHandoff, type HandoffContext } from './instrument-handoff'
|
|
5
|
+
|
|
6
|
+
const CTX: HandoffContext = {
|
|
7
|
+
operation: 'signMessage',
|
|
8
|
+
connectionMode: 'injected',
|
|
9
|
+
walletId: 'metamask',
|
|
10
|
+
namespace: 'eip155',
|
|
11
|
+
chainId: 'eip155:1'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function record(em: EventManager) {
|
|
15
|
+
const seen: Array<{ name: string; data: unknown }> = []
|
|
16
|
+
for (const name of [
|
|
17
|
+
'awaitingWallet',
|
|
18
|
+
'walletSucceeded',
|
|
19
|
+
'walletRejected',
|
|
20
|
+
'walletFailed',
|
|
21
|
+
'walletTimedOut'
|
|
22
|
+
] as const) {
|
|
23
|
+
em.on(name, data => seen.push({ name, data }))
|
|
24
|
+
}
|
|
25
|
+
return seen
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('instrumentHandoff', () => {
|
|
29
|
+
it('emits awaitingWallet then walletSucceeded on success', async () => {
|
|
30
|
+
const em = new EventManager()
|
|
31
|
+
const seen = record(em)
|
|
32
|
+
const result = await instrumentHandoff(
|
|
33
|
+
{ eventManager: em, timeoutMs: 0 },
|
|
34
|
+
CTX,
|
|
35
|
+
async () => 'sig'
|
|
36
|
+
)
|
|
37
|
+
expect(result).toBe('sig')
|
|
38
|
+
expect(seen.map(s => s.name)).toEqual(['awaitingWallet', 'walletSucceeded'])
|
|
39
|
+
const start = seen[0].data as { handoffId: string }
|
|
40
|
+
const end = seen[1].data as { handoffId: string }
|
|
41
|
+
expect(end.handoffId).toBe(start.handoffId)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('emits walletRejected for an explicit user rejection', async () => {
|
|
45
|
+
const em = new EventManager()
|
|
46
|
+
const seen = record(em)
|
|
47
|
+
await expect(
|
|
48
|
+
instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, async () => {
|
|
49
|
+
throw new WalletConnectorError({
|
|
50
|
+
type: 'rejected',
|
|
51
|
+
message: 'user said no'
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
).rejects.toThrow('user said no')
|
|
55
|
+
expect(seen.map(s => s.name)).toEqual(['awaitingWallet', 'walletRejected'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('emits walletRejected for a raw EIP-1193 4001', async () => {
|
|
59
|
+
const em = new EventManager()
|
|
60
|
+
const seen = record(em)
|
|
61
|
+
await expect(
|
|
62
|
+
instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, async () => {
|
|
63
|
+
throw Object.assign(new Error('rejected'), { code: 4001 })
|
|
64
|
+
})
|
|
65
|
+
).rejects.toThrow()
|
|
66
|
+
expect(seen.map(s => s.name)).toEqual(['awaitingWallet', 'walletRejected'])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('emits walletFailed (with scrubbed error) for any other error', async () => {
|
|
70
|
+
const em = new EventManager()
|
|
71
|
+
const seen = record(em)
|
|
72
|
+
await expect(
|
|
73
|
+
instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, async () => {
|
|
74
|
+
throw new Error('rpc exploded')
|
|
75
|
+
})
|
|
76
|
+
).rejects.toThrow('rpc exploded')
|
|
77
|
+
expect(seen.map(s => s.name)).toEqual(['awaitingWallet', 'walletFailed'])
|
|
78
|
+
const failed = seen[1].data as { error: { type: string; message: string } }
|
|
79
|
+
expect(failed.error.type).toBe('unknown')
|
|
80
|
+
expect(failed.error.message).toBe('rpc exploded')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('emits NO terminal for an intentional abort', async () => {
|
|
84
|
+
const em = new EventManager()
|
|
85
|
+
const seen = record(em)
|
|
86
|
+
await expect(
|
|
87
|
+
instrumentHandoff({ eventManager: em, timeoutMs: 0 }, CTX, async () => {
|
|
88
|
+
throw new DOMException('Aborted', 'AbortError')
|
|
89
|
+
})
|
|
90
|
+
).rejects.toThrow()
|
|
91
|
+
expect(seen.map(s => s.name)).toEqual(['awaitingWallet'])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('timeout marker', () => {
|
|
95
|
+
beforeEach(() => vi.useFakeTimers())
|
|
96
|
+
afterEach(() => vi.useRealTimers())
|
|
97
|
+
|
|
98
|
+
it('fires walletTimedOut while pending, then still resolves with walletSucceeded', async () => {
|
|
99
|
+
const em = new EventManager()
|
|
100
|
+
const seen = record(em)
|
|
101
|
+
let resolveFn: (v: string) => void = () => {}
|
|
102
|
+
const promise = instrumentHandoff(
|
|
103
|
+
{ eventManager: em, timeoutMs: 1000 },
|
|
104
|
+
CTX,
|
|
105
|
+
() => new Promise<string>(r => (resolveFn = r))
|
|
106
|
+
)
|
|
107
|
+
// Deadline passes with the op still pending → side marker fires.
|
|
108
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
109
|
+
expect(seen.map(s => s.name)).toContain('walletTimedOut')
|
|
110
|
+
// The op then completes → real terminal still fires.
|
|
111
|
+
resolveFn('late-sig')
|
|
112
|
+
await expect(promise).resolves.toBe('late-sig')
|
|
113
|
+
expect(seen.map(s => s.name)).toEqual([
|
|
114
|
+
'awaitingWallet',
|
|
115
|
+
'walletTimedOut',
|
|
116
|
+
'walletSucceeded'
|
|
117
|
+
])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('does NOT fire walletTimedOut for an op that settles before the deadline', async () => {
|
|
121
|
+
const em = new EventManager()
|
|
122
|
+
const seen = record(em)
|
|
123
|
+
await instrumentHandoff(
|
|
124
|
+
{ eventManager: em, timeoutMs: 1000 },
|
|
125
|
+
CTX,
|
|
126
|
+
async () => 'fast'
|
|
127
|
+
)
|
|
128
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
129
|
+
expect(seen.map(s => s.name)).toEqual([
|
|
130
|
+
'awaitingWallet',
|
|
131
|
+
'walletSucceeded'
|
|
132
|
+
])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('never arms the timer for tonConnect (already bounded by TON)', async () => {
|
|
136
|
+
const em = new EventManager()
|
|
137
|
+
const seen = record(em)
|
|
138
|
+
let resolveFn: (v: string) => void = () => {}
|
|
139
|
+
const promise = instrumentHandoff(
|
|
140
|
+
{ eventManager: em, timeoutMs: 1000 },
|
|
141
|
+
{ ...CTX, connectionMode: 'tonConnect' },
|
|
142
|
+
() => new Promise<string>(r => (resolveFn = r))
|
|
143
|
+
)
|
|
144
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
145
|
+
expect(seen.map(s => s.name)).not.toContain('walletTimedOut')
|
|
146
|
+
resolveFn('ok')
|
|
147
|
+
await promise
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConnectionMode,
|
|
3
|
+
Namespace,
|
|
4
|
+
NetworkId
|
|
5
|
+
} from '@meshconnect/uwc-types'
|
|
6
|
+
import type { EventManager } from '../managers/event-manager'
|
|
7
|
+
import type { UWCOperation } from '../events'
|
|
8
|
+
import { generateId } from '../utils/id'
|
|
9
|
+
import {
|
|
10
|
+
isAbortError,
|
|
11
|
+
isUserRejection,
|
|
12
|
+
toWalletError
|
|
13
|
+
} from '../utils/to-wallet-error'
|
|
14
|
+
import { derivePlatform, deriveWalletFlowType } from './telemetry'
|
|
15
|
+
|
|
16
|
+
/** Routing facets known at the moment of a wallet handoff. No address, no payload. */
|
|
17
|
+
export interface HandoffContext {
|
|
18
|
+
operation: UWCOperation
|
|
19
|
+
connectionMode: ConnectionMode
|
|
20
|
+
walletId?: string | undefined
|
|
21
|
+
namespace?: Namespace | undefined
|
|
22
|
+
chainId?: NetworkId | undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface InstrumentHandoffOptions {
|
|
26
|
+
eventManager: EventManager
|
|
27
|
+
/**
|
|
28
|
+
* Deadline after which `walletTimedOut` fires as a SIDE marker (never rejects).
|
|
29
|
+
* `0` disables the marker. `tonConnect` is always skipped — TON bounds its own
|
|
30
|
+
* wallet response internally, so a second observational timer would be noise.
|
|
31
|
+
*/
|
|
32
|
+
timeoutMs: number
|
|
33
|
+
/** Clock injectable for deterministic tests. */
|
|
34
|
+
now?: () => number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Instrument one wallet-boundary op so the blind window is measured.
|
|
39
|
+
*
|
|
40
|
+
* Emits `awaitingWallet` immediately (before the await that hands control to the
|
|
41
|
+
* wallet), then exactly one terminal — `walletSucceeded` (success),
|
|
42
|
+
* `walletRejected` (explicit user no), or `walletFailed` (any other error) — all
|
|
43
|
+
* carrying the same `handoffId` and a `durationMs`. An intentional abort emits no
|
|
44
|
+
* terminal (the caller cancelled; it isn't a wallet outcome).
|
|
45
|
+
*
|
|
46
|
+
* A non-cancelling `walletTimedOut` marker fires if the deadline passes while the
|
|
47
|
+
* op is still pending; it is cleared the instant the op settles, so a fast op
|
|
48
|
+
* never false-fires it, and it NEVER rejects/aborts/mutates state — the wrapped
|
|
49
|
+
* promise stays the single terminal source of truth. This is a deliberate
|
|
50
|
+
* deviation from TON's cancelling `withWalletResponseTimeout`.
|
|
51
|
+
*/
|
|
52
|
+
export async function instrumentHandoff<T>(
|
|
53
|
+
options: InstrumentHandoffOptions,
|
|
54
|
+
context: HandoffContext,
|
|
55
|
+
fn: () => Promise<T>
|
|
56
|
+
): Promise<T> {
|
|
57
|
+
const { eventManager, timeoutMs } = options
|
|
58
|
+
const now = options.now ?? Date.now
|
|
59
|
+
|
|
60
|
+
const handoffId = generateId()
|
|
61
|
+
const platform = derivePlatform()
|
|
62
|
+
const start = now()
|
|
63
|
+
|
|
64
|
+
eventManager.emit('awaitingWallet', {
|
|
65
|
+
operation: context.operation,
|
|
66
|
+
connectionMode: context.connectionMode,
|
|
67
|
+
walletId: context.walletId,
|
|
68
|
+
namespace: context.namespace,
|
|
69
|
+
chainId: context.chainId,
|
|
70
|
+
handoffId,
|
|
71
|
+
walletFlowType: deriveWalletFlowType(context.connectionMode, platform),
|
|
72
|
+
platform,
|
|
73
|
+
timestamp: start
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const shouldTime = timeoutMs > 0 && context.connectionMode !== 'tonConnect'
|
|
77
|
+
const timer: ReturnType<typeof setTimeout> | undefined = shouldTime
|
|
78
|
+
? setTimeout(() => {
|
|
79
|
+
eventManager.emit('walletTimedOut', {
|
|
80
|
+
handoffId,
|
|
81
|
+
operation: context.operation,
|
|
82
|
+
durationMs: now() - start
|
|
83
|
+
})
|
|
84
|
+
}, timeoutMs)
|
|
85
|
+
: undefined
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const result = await fn()
|
|
89
|
+
eventManager.emit('walletSucceeded', {
|
|
90
|
+
handoffId,
|
|
91
|
+
operation: context.operation,
|
|
92
|
+
durationMs: now() - start
|
|
93
|
+
})
|
|
94
|
+
return result
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// Caller-driven cancellation isn't a wallet outcome — leave it unterminated,
|
|
97
|
+
// mirroring the façade's `emitError` AbortError skip. Checked BEFORE rejection
|
|
98
|
+
// is safe: real user rejections surface as WalletConnectorError{rejected} or
|
|
99
|
+
// EIP-1193 4001 (see isUserRejection), never as an AbortError — within UWC's
|
|
100
|
+
// dependency surface AbortError is produced only by the caller's AbortSignal.
|
|
101
|
+
if (isAbortError(error)) throw error
|
|
102
|
+
|
|
103
|
+
const durationMs = now() - start
|
|
104
|
+
if (isUserRejection(error)) {
|
|
105
|
+
eventManager.emit('walletRejected', {
|
|
106
|
+
handoffId,
|
|
107
|
+
operation: context.operation,
|
|
108
|
+
durationMs
|
|
109
|
+
})
|
|
110
|
+
} else {
|
|
111
|
+
eventManager.emit('walletFailed', {
|
|
112
|
+
handoffId,
|
|
113
|
+
operation: context.operation,
|
|
114
|
+
durationMs,
|
|
115
|
+
error: toWalletError(error)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
throw error
|
|
119
|
+
} finally {
|
|
120
|
+
if (timer) clearTimeout(timer)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
})
|