@sip-protocol/sdk 0.1.0

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.
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Input validation utilities for SIP Protocol SDK
3
+ *
4
+ * Provides comprehensive validation for all public API inputs.
5
+ */
6
+
7
+ import type {
8
+ PrivacyLevel,
9
+ ChainId,
10
+ Asset,
11
+ IntentInput,
12
+ IntentOutput,
13
+ CreateIntentParams,
14
+ HexString,
15
+ } from '@sip-protocol/types'
16
+ import { ValidationError } from './errors'
17
+
18
+ // ─── Chain IDs ─────────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Valid chain identifiers
22
+ */
23
+ const VALID_CHAIN_IDS: readonly ChainId[] = [
24
+ 'solana',
25
+ 'ethereum',
26
+ 'near',
27
+ 'zcash',
28
+ 'polygon',
29
+ 'arbitrum',
30
+ 'optimism',
31
+ 'base',
32
+ ] as const
33
+
34
+ /**
35
+ * Check if a string is a valid chain ID
36
+ */
37
+ export function isValidChainId(chain: string): chain is ChainId {
38
+ return VALID_CHAIN_IDS.includes(chain as ChainId)
39
+ }
40
+
41
+ // ─── Privacy Levels ────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Valid privacy levels
45
+ */
46
+ const VALID_PRIVACY_LEVELS: readonly PrivacyLevel[] = [
47
+ 'transparent',
48
+ 'shielded',
49
+ 'compliant',
50
+ ] as unknown as readonly PrivacyLevel[]
51
+
52
+ /**
53
+ * Check if a value is a valid privacy level
54
+ */
55
+ export function isValidPrivacyLevel(level: unknown): level is PrivacyLevel {
56
+ if (typeof level !== 'string') return false
57
+ return ['transparent', 'shielded', 'compliant'].includes(level)
58
+ }
59
+
60
+ // ─── Hex Strings ───────────────────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Check if a string is valid hex format (with 0x prefix)
64
+ */
65
+ export function isValidHex(value: string): value is HexString {
66
+ if (typeof value !== 'string') return false
67
+ if (!value.startsWith('0x')) return false
68
+ const hex = value.slice(2)
69
+ if (hex.length === 0) return false
70
+ return /^[0-9a-fA-F]+$/.test(hex)
71
+ }
72
+
73
+ /**
74
+ * Check if a hex string has a specific byte length
75
+ */
76
+ export function isValidHexLength(value: string, byteLength: number): boolean {
77
+ if (!isValidHex(value)) return false
78
+ const hex = value.slice(2)
79
+ return hex.length === byteLength * 2
80
+ }
81
+
82
+ // ─── Amount Validation ─────────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Check if an amount is valid (positive bigint)
86
+ */
87
+ export function isValidAmount(value: unknown): value is bigint {
88
+ return typeof value === 'bigint' && value > 0n
89
+ }
90
+
91
+ /**
92
+ * Check if an amount is non-negative
93
+ */
94
+ export function isNonNegativeAmount(value: unknown): value is bigint {
95
+ return typeof value === 'bigint' && value >= 0n
96
+ }
97
+
98
+ // ─── Slippage Validation ───────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * Check if slippage is in valid range (0 to 1, exclusive of 1)
102
+ */
103
+ export function isValidSlippage(value: number): boolean {
104
+ return typeof value === 'number' && !isNaN(value) && value >= 0 && value < 1
105
+ }
106
+
107
+ // ─── Stealth Address Validation ────────────────────────────────────────────────
108
+
109
+ /**
110
+ * SIP stealth meta-address format:
111
+ * sip:<chain>:<spendingKey>:<viewingKey>
112
+ * Each key is 33 bytes compressed (66 hex chars with 0x prefix)
113
+ */
114
+ const STEALTH_META_ADDRESS_REGEX = /^sip:[a-z]+:0x[0-9a-fA-F]{66}:0x[0-9a-fA-F]{66}$/
115
+
116
+ /**
117
+ * Check if a string is a valid stealth meta-address
118
+ */
119
+ export function isValidStealthMetaAddress(addr: string): boolean {
120
+ if (typeof addr !== 'string') return false
121
+ return STEALTH_META_ADDRESS_REGEX.test(addr)
122
+ }
123
+
124
+ /**
125
+ * Check if a public key is valid (compressed secp256k1: 33 bytes)
126
+ */
127
+ export function isValidCompressedPublicKey(key: string): boolean {
128
+ if (!isValidHexLength(key, 33)) return false
129
+ // Compressed keys start with 02 or 03
130
+ const prefix = key.slice(2, 4)
131
+ return prefix === '02' || prefix === '03'
132
+ }
133
+
134
+ /**
135
+ * Check if a private key is valid (32 bytes)
136
+ */
137
+ export function isValidPrivateKey(key: string): boolean {
138
+ return isValidHexLength(key, 32)
139
+ }
140
+
141
+ // ─── Asset Validation ──────────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Validate an asset object
145
+ */
146
+ export function validateAsset(asset: unknown, field: string): asserts asset is Asset {
147
+ if (!asset || typeof asset !== 'object') {
148
+ throw new ValidationError('must be an object', field)
149
+ }
150
+
151
+ const a = asset as Partial<Asset>
152
+
153
+ if (!a.chain || !isValidChainId(a.chain)) {
154
+ throw new ValidationError(
155
+ `invalid chain '${a.chain}', must be one of: ${VALID_CHAIN_IDS.join(', ')}`,
156
+ `${field}.chain`
157
+ )
158
+ }
159
+
160
+ if (typeof a.symbol !== 'string' || a.symbol.length === 0) {
161
+ throw new ValidationError('symbol must be a non-empty string', `${field}.symbol`)
162
+ }
163
+
164
+ if (a.address !== null && !isValidHex(a.address as string)) {
165
+ throw new ValidationError('address must be null or valid hex string', `${field}.address`)
166
+ }
167
+
168
+ if (typeof a.decimals !== 'number' || !Number.isInteger(a.decimals) || a.decimals < 0) {
169
+ throw new ValidationError('decimals must be a non-negative integer', `${field}.decimals`)
170
+ }
171
+ }
172
+
173
+ // ─── Intent Input Validation ───────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Validate intent input
177
+ */
178
+ export function validateIntentInput(input: unknown, field: string = 'input'): asserts input is IntentInput {
179
+ if (!input || typeof input !== 'object') {
180
+ throw new ValidationError('must be an object', field)
181
+ }
182
+
183
+ const i = input as Partial<IntentInput>
184
+
185
+ // Validate asset
186
+ validateAsset(i.asset, `${field}.asset`)
187
+
188
+ // Validate amount
189
+ if (!isValidAmount(i.amount)) {
190
+ throw new ValidationError(
191
+ 'amount must be a positive bigint',
192
+ `${field}.amount`,
193
+ { received: typeof i.amount, value: String(i.amount) }
194
+ )
195
+ }
196
+ }
197
+
198
+ // ─── Intent Output Validation ──────────────────────────────────────────────────
199
+
200
+ /**
201
+ * Validate intent output
202
+ */
203
+ export function validateIntentOutput(output: unknown, field: string = 'output'): asserts output is IntentOutput {
204
+ if (!output || typeof output !== 'object') {
205
+ throw new ValidationError('must be an object', field)
206
+ }
207
+
208
+ const o = output as Partial<IntentOutput>
209
+
210
+ // Validate asset
211
+ validateAsset(o.asset, `${field}.asset`)
212
+
213
+ // Validate minAmount
214
+ if (!isNonNegativeAmount(o.minAmount)) {
215
+ throw new ValidationError(
216
+ 'minAmount must be a non-negative bigint',
217
+ `${field}.minAmount`,
218
+ { received: typeof o.minAmount, value: String(o.minAmount) }
219
+ )
220
+ }
221
+
222
+ // Validate maxSlippage
223
+ if (!isValidSlippage(o.maxSlippage as number)) {
224
+ throw new ValidationError(
225
+ 'maxSlippage must be a number between 0 and 1 (exclusive)',
226
+ `${field}.maxSlippage`,
227
+ { received: o.maxSlippage }
228
+ )
229
+ }
230
+ }
231
+
232
+ // ─── Create Intent Params Validation ───────────────────────────────────────────
233
+
234
+ /**
235
+ * Validate CreateIntentParams
236
+ */
237
+ export function validateCreateIntentParams(params: unknown): asserts params is CreateIntentParams {
238
+ if (!params || typeof params !== 'object') {
239
+ throw new ValidationError('params must be an object')
240
+ }
241
+
242
+ const p = params as Partial<CreateIntentParams>
243
+
244
+ // Required: input
245
+ if (!p.input) {
246
+ throw new ValidationError('input is required', 'input')
247
+ }
248
+ validateIntentInput(p.input, 'input')
249
+
250
+ // Required: output
251
+ if (!p.output) {
252
+ throw new ValidationError('output is required', 'output')
253
+ }
254
+ validateIntentOutput(p.output, 'output')
255
+
256
+ // Required: privacy
257
+ if (!p.privacy) {
258
+ throw new ValidationError('privacy is required', 'privacy')
259
+ }
260
+ if (!isValidPrivacyLevel(p.privacy)) {
261
+ throw new ValidationError(
262
+ `invalid privacy level '${p.privacy}', must be one of: transparent, shielded, compliant`,
263
+ 'privacy'
264
+ )
265
+ }
266
+
267
+ // Conditional: recipientMetaAddress for shielded modes
268
+ if ((p.privacy === 'shielded' || p.privacy === 'compliant') && p.recipientMetaAddress) {
269
+ if (!isValidStealthMetaAddress(p.recipientMetaAddress)) {
270
+ throw new ValidationError(
271
+ 'invalid stealth meta-address format, expected: sip:<chain>:<spendingKey>:<viewingKey>',
272
+ 'recipientMetaAddress'
273
+ )
274
+ }
275
+ }
276
+
277
+ // Conditional: viewingKey for compliant mode
278
+ if (p.privacy === 'compliant' && !p.viewingKey) {
279
+ throw new ValidationError(
280
+ 'viewingKey is required for compliant mode',
281
+ 'viewingKey'
282
+ )
283
+ }
284
+
285
+ if (p.viewingKey && !isValidHex(p.viewingKey)) {
286
+ throw new ValidationError(
287
+ 'viewingKey must be a valid hex string',
288
+ 'viewingKey'
289
+ )
290
+ }
291
+
292
+ // Optional: ttl
293
+ if (p.ttl !== undefined) {
294
+ if (typeof p.ttl !== 'number' || !Number.isInteger(p.ttl) || p.ttl <= 0) {
295
+ throw new ValidationError(
296
+ 'ttl must be a positive integer (seconds)',
297
+ 'ttl',
298
+ { received: p.ttl }
299
+ )
300
+ }
301
+ }
302
+ }
303
+
304
+ // ─── Viewing Key Validation ────────────────────────────────────────────────────
305
+
306
+ /**
307
+ * Validate a viewing key (32-byte hex string)
308
+ */
309
+ export function validateViewingKey(key: unknown, field: string = 'viewingKey'): void {
310
+ if (!key || typeof key !== 'string') {
311
+ throw new ValidationError('must be a string', field)
312
+ }
313
+
314
+ if (!isValidHex(key)) {
315
+ throw new ValidationError('must be a valid hex string with 0x prefix', field)
316
+ }
317
+
318
+ if (!isValidHexLength(key, 32)) {
319
+ throw new ValidationError('must be 32 bytes (64 hex characters)', field)
320
+ }
321
+ }
322
+
323
+ // ─── Scalar/Point Validation for Cryptographic Operations ──────────────────────
324
+
325
+ /**
326
+ * secp256k1 curve order (n)
327
+ */
328
+ const SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n
329
+
330
+ /**
331
+ * Check if a bigint is a valid scalar (1 <= x < n)
332
+ */
333
+ export function isValidScalar(value: bigint): boolean {
334
+ return value > 0n && value < SECP256K1_ORDER
335
+ }
336
+
337
+ /**
338
+ * Validate a scalar value
339
+ */
340
+ export function validateScalar(value: unknown, field: string): asserts value is bigint {
341
+ if (typeof value !== 'bigint') {
342
+ throw new ValidationError('must be a bigint', field)
343
+ }
344
+
345
+ if (!isValidScalar(value)) {
346
+ throw new ValidationError(
347
+ 'must be in range (0, curve order)',
348
+ field,
349
+ { curveOrder: SECP256K1_ORDER.toString(16) }
350
+ )
351
+ }
352
+ }
353
+
354
+ // ─── Timestamp Validation ──────────────────────────────────────────────────────
355
+
356
+ /**
357
+ * Validate a timestamp (positive integer, not in the past)
358
+ */
359
+ export function validateTimestamp(
360
+ value: unknown,
361
+ field: string,
362
+ options: { allowPast?: boolean } = {}
363
+ ): asserts value is number {
364
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
365
+ throw new ValidationError('must be a non-negative integer', field)
366
+ }
367
+
368
+ if (!options.allowPast && value < Date.now()) {
369
+ throw new ValidationError('must not be in the past', field)
370
+ }
371
+ }
372
+
373
+ // ─── Composite Validators ──────────────────────────────────────────────────────
374
+
375
+ /**
376
+ * Validate multiple conditions and collect all errors
377
+ */
378
+ export function validateAll(validators: Array<() => void>): void {
379
+ const errors: ValidationError[] = []
380
+
381
+ for (const validator of validators) {
382
+ try {
383
+ validator()
384
+ } catch (e) {
385
+ if (e instanceof ValidationError) {
386
+ errors.push(e)
387
+ } else {
388
+ throw e
389
+ }
390
+ }
391
+ }
392
+
393
+ if (errors.length === 1) {
394
+ throw errors[0]
395
+ }
396
+
397
+ if (errors.length > 1) {
398
+ const messages = errors.map(e => e.message).join('; ')
399
+ throw new ValidationError(`Multiple validation errors: ${messages}`)
400
+ }
401
+ }