@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.
package/src/intent.ts ADDED
@@ -0,0 +1,488 @@
1
+ /**
2
+ * ShieldedIntent class for SIP Protocol
3
+ *
4
+ * Main interface for creating and managing shielded intents.
5
+ */
6
+
7
+ import {
8
+ SIP_VERSION,
9
+ IntentStatus,
10
+ PrivacyLevel as PrivacyLevelEnum,
11
+ type ShieldedIntent,
12
+ type CreateIntentParams,
13
+ type TrackedIntent,
14
+ type Quote,
15
+ type FulfillmentResult,
16
+ type StealthMetaAddress,
17
+ type Commitment,
18
+ type HexString,
19
+ type Hash,
20
+ type PrivacyLevel,
21
+ } from '@sip-protocol/types'
22
+ import { generateStealthAddress, decodeStealthMetaAddress } from './stealth'
23
+ import {
24
+ createCommitment,
25
+ generateIntentId,
26
+ hash,
27
+ } from './crypto'
28
+ import { hexToBytes, bytesToHex } from '@noble/hashes/utils'
29
+ import { sha256 } from '@noble/hashes/sha256'
30
+ import { getPrivacyConfig, generateViewingKey } from './privacy'
31
+ import type { ProofProvider } from './proofs'
32
+ import { ValidationError } from './errors'
33
+ import {
34
+ validateCreateIntentParams,
35
+ isValidChainId,
36
+ isValidAmount,
37
+ isValidSlippage,
38
+ isValidPrivacyLevel,
39
+ isValidStealthMetaAddress,
40
+ } from './validation'
41
+
42
+ /**
43
+ * Options for creating a shielded intent
44
+ */
45
+ export interface CreateIntentOptions {
46
+ /** Sender address (for ownership proof) */
47
+ senderAddress?: string
48
+ /**
49
+ * Proof provider for generating ZK proofs
50
+ * If provided and privacy level requires proofs, they will be generated automatically
51
+ */
52
+ proofProvider?: ProofProvider
53
+ }
54
+
55
+ /**
56
+ * Builder class for creating shielded intents
57
+ */
58
+ export class IntentBuilder {
59
+ private params: Partial<CreateIntentParams> = {}
60
+ private senderAddress?: string
61
+ private proofProvider?: ProofProvider
62
+
63
+ /**
64
+ * Set the input for the intent
65
+ *
66
+ * @throws {ValidationError} If chain or amount is invalid
67
+ */
68
+ input(
69
+ chain: string,
70
+ token: string,
71
+ amount: number | bigint,
72
+ sourceAddress?: string,
73
+ ): this {
74
+ // Validate chain
75
+ if (!isValidChainId(chain)) {
76
+ throw new ValidationError(
77
+ `invalid chain '${chain}', must be one of: solana, ethereum, near, zcash, polygon, arbitrum, optimism, base`,
78
+ 'input.chain'
79
+ )
80
+ }
81
+
82
+ // Validate token
83
+ if (!token || typeof token !== 'string' || token.trim().length === 0) {
84
+ throw new ValidationError('token must be a non-empty string', 'input.token')
85
+ }
86
+
87
+ // Validate amount
88
+ const amountBigInt = typeof amount === 'number' ? BigInt(Math.floor(amount * 1e18)) : amount
89
+ if (!isValidAmount(amountBigInt)) {
90
+ throw new ValidationError('amount must be positive', 'input.amount')
91
+ }
92
+
93
+ this.params.input = {
94
+ asset: {
95
+ chain: chain as any,
96
+ symbol: token,
97
+ address: null,
98
+ decimals: 18, // Default, should be looked up
99
+ },
100
+ amount: amountBigInt,
101
+ sourceAddress,
102
+ }
103
+ this.senderAddress = sourceAddress
104
+ return this
105
+ }
106
+
107
+ /**
108
+ * Set the output for the intent
109
+ *
110
+ * @throws {ValidationError} If chain is invalid
111
+ */
112
+ output(chain: string, token: string, minAmount?: number | bigint): this {
113
+ // Validate chain
114
+ if (!isValidChainId(chain)) {
115
+ throw new ValidationError(
116
+ `invalid chain '${chain}', must be one of: solana, ethereum, near, zcash, polygon, arbitrum, optimism, base`,
117
+ 'output.chain'
118
+ )
119
+ }
120
+
121
+ // Validate token
122
+ if (!token || typeof token !== 'string' || token.trim().length === 0) {
123
+ throw new ValidationError('token must be a non-empty string', 'output.token')
124
+ }
125
+
126
+ const minAmountBigInt = minAmount
127
+ ? typeof minAmount === 'number'
128
+ ? BigInt(Math.floor(minAmount * 1e18))
129
+ : minAmount
130
+ : 0n
131
+
132
+ // minAmount can be 0 (no minimum), but not negative
133
+ if (minAmountBigInt < 0n) {
134
+ throw new ValidationError('minAmount cannot be negative', 'output.minAmount')
135
+ }
136
+
137
+ this.params.output = {
138
+ asset: {
139
+ chain: chain as any,
140
+ symbol: token,
141
+ address: null,
142
+ decimals: 18,
143
+ },
144
+ minAmount: minAmountBigInt,
145
+ maxSlippage: 0.01, // 1% default
146
+ }
147
+ return this
148
+ }
149
+
150
+ /**
151
+ * Set the privacy level
152
+ *
153
+ * @throws {ValidationError} If privacy level is invalid
154
+ */
155
+ privacy(level: PrivacyLevel): this {
156
+ if (!isValidPrivacyLevel(level)) {
157
+ throw new ValidationError(
158
+ `invalid privacy level '${level}', must be one of: transparent, shielded, compliant`,
159
+ 'privacy'
160
+ )
161
+ }
162
+ this.params.privacy = level
163
+ return this
164
+ }
165
+
166
+ /**
167
+ * Set the recipient's stealth meta-address
168
+ *
169
+ * @throws {ValidationError} If stealth meta-address format is invalid
170
+ */
171
+ recipient(metaAddress: string): this {
172
+ if (metaAddress && !isValidStealthMetaAddress(metaAddress)) {
173
+ throw new ValidationError(
174
+ 'invalid stealth meta-address format, expected: sip:<chain>:<spendingKey>:<viewingKey>',
175
+ 'recipientMetaAddress'
176
+ )
177
+ }
178
+ this.params.recipientMetaAddress = metaAddress
179
+ return this
180
+ }
181
+
182
+ /**
183
+ * Set slippage tolerance
184
+ *
185
+ * @param percent - Slippage percentage (e.g., 1 for 1%)
186
+ * @throws {ValidationError} If slippage is out of range
187
+ */
188
+ slippage(percent: number): this {
189
+ const slippageDecimal = percent / 100
190
+ if (!isValidSlippage(slippageDecimal)) {
191
+ throw new ValidationError(
192
+ 'slippage must be a non-negative number less than 100%',
193
+ 'maxSlippage',
194
+ { received: percent, asDecimal: slippageDecimal }
195
+ )
196
+ }
197
+ if (this.params.output) {
198
+ this.params.output.maxSlippage = slippageDecimal
199
+ }
200
+ return this
201
+ }
202
+
203
+ /**
204
+ * Set time-to-live in seconds
205
+ *
206
+ * @throws {ValidationError} If TTL is not a positive integer
207
+ */
208
+ ttl(seconds: number): this {
209
+ if (typeof seconds !== 'number' || !Number.isInteger(seconds) || seconds <= 0) {
210
+ throw new ValidationError(
211
+ 'ttl must be a positive integer (seconds)',
212
+ 'ttl',
213
+ { received: seconds }
214
+ )
215
+ }
216
+ this.params.ttl = seconds
217
+ return this
218
+ }
219
+
220
+ /**
221
+ * Set the proof provider for automatic proof generation
222
+ *
223
+ * @param provider - The proof provider to use
224
+ * @returns this for chaining
225
+ *
226
+ * @example
227
+ * ```typescript
228
+ * const intent = await builder
229
+ * .input('near', 'NEAR', 100n)
230
+ * .output('zcash', 'ZEC', 95n)
231
+ * .privacy(PrivacyLevel.SHIELDED)
232
+ * .withProvider(mockProvider)
233
+ * .build()
234
+ * ```
235
+ */
236
+ withProvider(provider: ProofProvider): this {
237
+ this.proofProvider = provider
238
+ return this
239
+ }
240
+
241
+ /**
242
+ * Build the shielded intent
243
+ *
244
+ * If a proof provider is set and the privacy level requires proofs,
245
+ * they will be generated automatically.
246
+ *
247
+ * @returns Promise resolving to the shielded intent
248
+ */
249
+ async build(): Promise<ShieldedIntent> {
250
+ return createShieldedIntent(this.params as CreateIntentParams, {
251
+ senderAddress: this.senderAddress,
252
+ proofProvider: this.proofProvider,
253
+ })
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Create a new shielded intent
259
+ *
260
+ * @param params - Intent creation parameters
261
+ * @param options - Optional configuration (sender address, proof provider)
262
+ * @returns Promise resolving to the shielded intent
263
+ *
264
+ * @example
265
+ * ```typescript
266
+ * // Without proof provider (proofs need to be attached later)
267
+ * const intent = await createShieldedIntent(params)
268
+ *
269
+ * // With proof provider (proofs generated automatically for SHIELDED/COMPLIANT)
270
+ * const intent = await createShieldedIntent(params, {
271
+ * senderAddress: wallet.address,
272
+ * proofProvider: mockProvider,
273
+ * })
274
+ * ```
275
+ */
276
+ export async function createShieldedIntent(
277
+ params: CreateIntentParams,
278
+ options?: CreateIntentOptions,
279
+ ): Promise<ShieldedIntent> {
280
+ // Comprehensive input validation
281
+ validateCreateIntentParams(params)
282
+
283
+ const { input, output, privacy, recipientMetaAddress, viewingKey, ttl = 300 } = params
284
+ const { senderAddress, proofProvider } = options ?? {}
285
+
286
+ // Get privacy configuration
287
+ // Compute viewing key hash the same way as generateViewingKey():
288
+ // Hash the raw key bytes, not the hex string
289
+ let viewingKeyHash: Hash | undefined
290
+ if (viewingKey) {
291
+ const keyHex = viewingKey.startsWith('0x') ? viewingKey.slice(2) : viewingKey
292
+ const keyBytes = hexToBytes(keyHex)
293
+ viewingKeyHash = `0x${bytesToHex(sha256(keyBytes))}` as Hash
294
+ }
295
+
296
+ const privacyConfig = getPrivacyConfig(
297
+ privacy,
298
+ viewingKey ? { key: viewingKey, path: 'm/0', hash: viewingKeyHash! } : undefined,
299
+ )
300
+
301
+ // Generate intent ID
302
+ const intentId = generateIntentId()
303
+
304
+ // Create commitments for private fields
305
+ const inputCommitment = createCommitment(input.amount)
306
+ const senderCommitment = createCommitment(
307
+ BigInt(senderAddress ? hash(senderAddress).slice(2, 18) : '0'),
308
+ )
309
+
310
+ // Generate stealth address for recipient (if shielded)
311
+ let recipientStealth
312
+ if (privacyConfig.useStealth && recipientMetaAddress) {
313
+ const metaAddress = decodeStealthMetaAddress(recipientMetaAddress)
314
+ const { stealthAddress } = generateStealthAddress(metaAddress)
315
+ recipientStealth = stealthAddress
316
+ } else {
317
+ // For transparent mode, create a placeholder
318
+ recipientStealth = {
319
+ address: '0x0' as HexString,
320
+ ephemeralPublicKey: '0x0' as HexString,
321
+ viewTag: 0,
322
+ }
323
+ }
324
+
325
+ const now = Math.floor(Date.now() / 1000)
326
+
327
+ // Generate proofs if provider is available and privacy level requires them
328
+ let fundingProof: import('@sip-protocol/types').ZKProof | undefined
329
+ let validityProof: import('@sip-protocol/types').ZKProof | undefined
330
+
331
+ const requiresProofs = privacy !== PrivacyLevelEnum.TRANSPARENT
332
+
333
+ if (requiresProofs && proofProvider && proofProvider.isReady) {
334
+ // Helper to convert HexString to Uint8Array
335
+ const hexToUint8 = (hex: HexString): Uint8Array => {
336
+ const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex
337
+ return hexToBytes(cleanHex)
338
+ }
339
+
340
+ // Generate funding proof
341
+ const fundingResult = await proofProvider.generateFundingProof({
342
+ balance: input.amount,
343
+ minimumRequired: output.minAmount,
344
+ blindingFactor: hexToUint8(inputCommitment.blindingFactor as HexString),
345
+ assetId: input.asset.symbol,
346
+ userAddress: senderAddress ?? '0x0',
347
+ ownershipSignature: new Uint8Array(64), // Placeholder - would come from wallet
348
+ })
349
+ fundingProof = fundingResult.proof
350
+
351
+ // Generate validity proof
352
+ const validityResult = await proofProvider.generateValidityProof({
353
+ intentHash: hash(intentId) as HexString,
354
+ senderAddress: senderAddress ?? '0x0',
355
+ senderBlinding: hexToUint8(senderCommitment.blindingFactor as HexString),
356
+ senderSecret: new Uint8Array(32), // Placeholder - would come from wallet
357
+ authorizationSignature: new Uint8Array(64), // Placeholder - would come from wallet
358
+ nonce: new Uint8Array(32), // Could use randomBytes here
359
+ timestamp: now,
360
+ expiry: now + ttl,
361
+ })
362
+ validityProof = validityResult.proof
363
+ }
364
+
365
+ return {
366
+ intentId,
367
+ version: SIP_VERSION,
368
+ privacyLevel: privacy,
369
+ createdAt: now,
370
+ expiry: now + ttl,
371
+
372
+ outputAsset: output.asset,
373
+ minOutputAmount: output.minAmount,
374
+ maxSlippage: output.maxSlippage,
375
+
376
+ inputCommitment,
377
+ senderCommitment,
378
+ recipientStealth,
379
+
380
+ // Proofs are undefined if:
381
+ // - TRANSPARENT mode (not required)
382
+ // - No proof provider given
383
+ // - Provider not ready
384
+ fundingProof: fundingProof as any,
385
+ validityProof: validityProof as any,
386
+
387
+ viewingKeyHash: privacyConfig.viewingKey?.hash,
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Attach proofs to a shielded intent
393
+ *
394
+ * For SHIELDED and COMPLIANT modes, proofs are required before the intent
395
+ * can be submitted. This function attaches the proofs to an intent.
396
+ *
397
+ * @param intent - The intent to attach proofs to
398
+ * @param fundingProof - The funding proof (balance >= minimum)
399
+ * @param validityProof - The validity proof (authorization)
400
+ * @returns The intent with proofs attached
401
+ */
402
+ export function attachProofs(
403
+ intent: ShieldedIntent,
404
+ fundingProof: import('@sip-protocol/types').ZKProof,
405
+ validityProof: import('@sip-protocol/types').ZKProof,
406
+ ): ShieldedIntent {
407
+ return {
408
+ ...intent,
409
+ fundingProof,
410
+ validityProof,
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Check if an intent has all required proofs
416
+ */
417
+ export function hasRequiredProofs(intent: ShieldedIntent): boolean {
418
+ // TRANSPARENT mode doesn't require proofs
419
+ if (intent.privacyLevel === 'transparent') {
420
+ return true
421
+ }
422
+
423
+ // SHIELDED and COMPLIANT modes require both proofs
424
+ return !!(intent.fundingProof && intent.validityProof)
425
+ }
426
+
427
+ /**
428
+ * Wrap a shielded intent with status tracking
429
+ */
430
+ export function trackIntent(intent: ShieldedIntent): TrackedIntent {
431
+ return {
432
+ ...intent,
433
+ status: IntentStatus.PENDING,
434
+ quotes: [],
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Check if an intent has expired
440
+ */
441
+ export function isExpired(intent: ShieldedIntent): boolean {
442
+ return Math.floor(Date.now() / 1000) > intent.expiry
443
+ }
444
+
445
+ /**
446
+ * Get time remaining until intent expires (in seconds)
447
+ */
448
+ export function getTimeRemaining(intent: ShieldedIntent): number {
449
+ const remaining = intent.expiry - Math.floor(Date.now() / 1000)
450
+ return Math.max(0, remaining)
451
+ }
452
+
453
+ /**
454
+ * Serialize a shielded intent to JSON
455
+ */
456
+ export function serializeIntent(intent: ShieldedIntent): string {
457
+ return JSON.stringify(intent, (_, value) =>
458
+ typeof value === 'bigint' ? value.toString() : value,
459
+ )
460
+ }
461
+
462
+ /**
463
+ * Deserialize a shielded intent from JSON
464
+ */
465
+ export function deserializeIntent(json: string): ShieldedIntent {
466
+ return JSON.parse(json, (key, value) => {
467
+ // Convert string numbers back to bigint for known fields
468
+ if (
469
+ typeof value === 'string' &&
470
+ /^\d+$/.test(value) &&
471
+ ['minOutputAmount', 'amount'].includes(key)
472
+ ) {
473
+ return BigInt(value)
474
+ }
475
+ return value
476
+ })
477
+ }
478
+
479
+ /**
480
+ * Get a human-readable summary of the intent
481
+ */
482
+ export function getIntentSummary(intent: ShieldedIntent): string {
483
+ const privacy = intent.privacyLevel.toUpperCase()
484
+ const output = intent.outputAsset.symbol
485
+ const expiry = new Date(intent.expiry * 1000).toISOString()
486
+
487
+ return `[${privacy}] Intent ${intent.intentId.slice(0, 16)}... → ${output} (expires: ${expiry})`
488
+ }