@sip-protocol/sdk 0.1.0 → 0.1.4

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,623 @@
1
+ /**
2
+ * Shielded Payments for SIP Protocol
3
+ *
4
+ * Provides privacy-preserving stablecoin transfers using stealth addresses
5
+ * and Pedersen commitments. Optimized for P2P payments with lower latency
6
+ * than cross-chain swaps.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * // Create a shielded USDC payment
11
+ * const payment = await new PaymentBuilder()
12
+ * .token('USDC', 'ethereum')
13
+ * .amount(100n * 10n ** 6n) // 100 USDC
14
+ * .recipient(recipientMetaAddress)
15
+ * .privacy('shielded')
16
+ * .memo('Payment for services')
17
+ * .build()
18
+ * ```
19
+ */
20
+
21
+ import {
22
+ SIP_VERSION,
23
+ PrivacyLevel,
24
+ PaymentStatus,
25
+ type ShieldedPayment,
26
+ type CreatePaymentParams,
27
+ type TrackedPayment,
28
+ type Asset,
29
+ type ChainId,
30
+ type StablecoinSymbol,
31
+ type HexString,
32
+ type Hash,
33
+ type PaymentPurpose,
34
+ } from '@sip-protocol/types'
35
+ import { sha256 } from '@noble/hashes/sha256'
36
+ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils'
37
+ import { xchacha20poly1305 } from '@noble/ciphers/chacha.js'
38
+ import { hkdf } from '@noble/hashes/hkdf'
39
+
40
+ import { generateStealthAddress, decodeStealthMetaAddress } from '../stealth'
41
+ import { createCommitment, generateIntentId, hash } from '../crypto'
42
+ import { getPrivacyConfig } from '../privacy'
43
+ import { ValidationError, ErrorCode } from '../errors'
44
+ import { isValidChainId, isValidPrivacyLevel, isValidStealthMetaAddress } from '../validation'
45
+ import { secureWipe } from '../secure-memory'
46
+ import { getStablecoin, isStablecoin, STABLECOIN_DECIMALS } from './stablecoins'
47
+ import type { ProofProvider } from '../proofs'
48
+
49
+ /**
50
+ * Options for creating a shielded payment
51
+ */
52
+ export interface CreatePaymentOptions {
53
+ /** Sender address (for ownership proof) */
54
+ senderAddress?: string
55
+ /** Proof provider for generating ZK proofs */
56
+ proofProvider?: ProofProvider
57
+ }
58
+
59
+ /**
60
+ * Builder class for creating shielded payments
61
+ *
62
+ * Provides a fluent interface for constructing privacy-preserving payments.
63
+ */
64
+ export class PaymentBuilder {
65
+ private _token?: Asset
66
+ private _amount?: bigint
67
+ private _recipientMetaAddress?: string
68
+ private _recipientAddress?: string
69
+ private _privacy: PrivacyLevel = PrivacyLevel.SHIELDED
70
+ private _viewingKey?: HexString
71
+ private _sourceChain?: ChainId
72
+ private _destinationChain?: ChainId
73
+ private _purpose?: PaymentPurpose
74
+ private _memo?: string
75
+ private _ttl: number = 3600 // 1 hour default
76
+ private _senderAddress?: string
77
+ private _proofProvider?: ProofProvider
78
+
79
+ /**
80
+ * Set the token to transfer
81
+ *
82
+ * @param tokenOrSymbol - Asset object or stablecoin symbol
83
+ * @param chain - Chain ID (required if using symbol)
84
+ */
85
+ token(tokenOrSymbol: Asset | StablecoinSymbol, chain?: ChainId): this {
86
+ if (typeof tokenOrSymbol === 'string') {
87
+ // It's a stablecoin symbol
88
+ if (!chain) {
89
+ throw new ValidationError(
90
+ 'chain is required when using stablecoin symbol',
91
+ 'chain',
92
+ undefined,
93
+ ErrorCode.MISSING_REQUIRED
94
+ )
95
+ }
96
+ if (!isStablecoin(tokenOrSymbol)) {
97
+ throw new ValidationError(
98
+ `unknown stablecoin: ${tokenOrSymbol}`,
99
+ 'token',
100
+ { received: tokenOrSymbol },
101
+ ErrorCode.INVALID_INPUT
102
+ )
103
+ }
104
+ const asset = getStablecoin(tokenOrSymbol, chain)
105
+ if (!asset) {
106
+ throw new ValidationError(
107
+ `${tokenOrSymbol} is not available on ${chain}`,
108
+ 'token',
109
+ { symbol: tokenOrSymbol, chain },
110
+ ErrorCode.INVALID_INPUT
111
+ )
112
+ }
113
+ this._token = asset
114
+ this._sourceChain = chain
115
+ } else {
116
+ // It's an Asset object
117
+ this._token = tokenOrSymbol
118
+ this._sourceChain = tokenOrSymbol.chain
119
+ }
120
+ return this
121
+ }
122
+
123
+ /**
124
+ * Set the amount to transfer (in smallest units)
125
+ *
126
+ * @param amount - Amount in token's smallest units
127
+ */
128
+ amount(amount: bigint): this {
129
+ if (amount <= 0n) {
130
+ throw new ValidationError(
131
+ 'amount must be positive',
132
+ 'amount',
133
+ { received: amount.toString() },
134
+ ErrorCode.INVALID_INPUT
135
+ )
136
+ }
137
+ this._amount = amount
138
+ return this
139
+ }
140
+
141
+ /**
142
+ * Set the amount in human-readable format
143
+ *
144
+ * @param amount - Human-readable amount (e.g., 100.50)
145
+ */
146
+ amountHuman(amount: number): this {
147
+ if (!this._token) {
148
+ throw new ValidationError(
149
+ 'token must be set before amountHuman',
150
+ 'token',
151
+ undefined,
152
+ ErrorCode.MISSING_REQUIRED
153
+ )
154
+ }
155
+ const decimals = this._token.decimals
156
+ this._amount = BigInt(Math.floor(amount * (10 ** decimals)))
157
+ return this
158
+ }
159
+
160
+ /**
161
+ * Set the recipient's stealth meta-address (for privacy modes)
162
+ */
163
+ recipient(metaAddress: string): this {
164
+ if (!isValidStealthMetaAddress(metaAddress)) {
165
+ throw new ValidationError(
166
+ 'invalid stealth meta-address format',
167
+ 'recipientMetaAddress',
168
+ undefined,
169
+ ErrorCode.INVALID_INPUT
170
+ )
171
+ }
172
+ this._recipientMetaAddress = metaAddress
173
+ this._recipientAddress = undefined // Clear direct address
174
+ return this
175
+ }
176
+
177
+ /**
178
+ * Set the recipient's direct address (for transparent mode)
179
+ */
180
+ recipientDirect(address: string): this {
181
+ if (!address || address.trim().length === 0) {
182
+ throw new ValidationError(
183
+ 'address must be a non-empty string',
184
+ 'recipientAddress',
185
+ undefined,
186
+ ErrorCode.INVALID_INPUT
187
+ )
188
+ }
189
+ this._recipientAddress = address
190
+ this._recipientMetaAddress = undefined // Clear stealth address
191
+ return this
192
+ }
193
+
194
+ /**
195
+ * Set the privacy level
196
+ */
197
+ privacy(level: PrivacyLevel): this {
198
+ if (!isValidPrivacyLevel(level)) {
199
+ throw new ValidationError(
200
+ `invalid privacy level: ${level}`,
201
+ 'privacy',
202
+ { received: level },
203
+ ErrorCode.INVALID_PRIVACY_LEVEL
204
+ )
205
+ }
206
+ this._privacy = level
207
+ return this
208
+ }
209
+
210
+ /**
211
+ * Set the viewing key (required for compliant mode)
212
+ */
213
+ viewingKey(key: HexString): this {
214
+ this._viewingKey = key
215
+ return this
216
+ }
217
+
218
+ /**
219
+ * Set the destination chain (for cross-chain payments)
220
+ */
221
+ destinationChain(chain: ChainId): this {
222
+ if (!isValidChainId(chain)) {
223
+ throw new ValidationError(
224
+ `invalid chain: ${chain}`,
225
+ 'destinationChain',
226
+ { received: chain },
227
+ ErrorCode.INVALID_INPUT
228
+ )
229
+ }
230
+ this._destinationChain = chain
231
+ return this
232
+ }
233
+
234
+ /**
235
+ * Set the payment purpose
236
+ */
237
+ purpose(purpose: PaymentPurpose): this {
238
+ this._purpose = purpose
239
+ return this
240
+ }
241
+
242
+ /**
243
+ * Set an optional memo/reference
244
+ */
245
+ memo(memo: string): this {
246
+ if (memo.length > 256) {
247
+ throw new ValidationError(
248
+ 'memo must be 256 characters or less',
249
+ 'memo',
250
+ { received: memo.length },
251
+ ErrorCode.INVALID_INPUT
252
+ )
253
+ }
254
+ this._memo = memo
255
+ return this
256
+ }
257
+
258
+ /**
259
+ * Set time-to-live in seconds
260
+ */
261
+ ttl(seconds: number): this {
262
+ if (seconds <= 0 || !Number.isInteger(seconds)) {
263
+ throw new ValidationError(
264
+ 'ttl must be a positive integer',
265
+ 'ttl',
266
+ { received: seconds },
267
+ ErrorCode.INVALID_INPUT
268
+ )
269
+ }
270
+ this._ttl = seconds
271
+ return this
272
+ }
273
+
274
+ /**
275
+ * Set the sender address
276
+ */
277
+ sender(address: string): this {
278
+ this._senderAddress = address
279
+ return this
280
+ }
281
+
282
+ /**
283
+ * Set the proof provider
284
+ */
285
+ withProvider(provider: ProofProvider): this {
286
+ this._proofProvider = provider
287
+ return this
288
+ }
289
+
290
+ /**
291
+ * Build the shielded payment
292
+ */
293
+ async build(): Promise<ShieldedPayment> {
294
+ // Validate required fields
295
+ if (!this._token) {
296
+ throw new ValidationError(
297
+ 'token is required',
298
+ 'token',
299
+ undefined,
300
+ ErrorCode.MISSING_REQUIRED
301
+ )
302
+ }
303
+ if (this._amount === undefined) {
304
+ throw new ValidationError(
305
+ 'amount is required',
306
+ 'amount',
307
+ undefined,
308
+ ErrorCode.MISSING_REQUIRED
309
+ )
310
+ }
311
+
312
+ // Build params
313
+ const params: CreatePaymentParams = {
314
+ token: this._token,
315
+ amount: this._amount,
316
+ recipientMetaAddress: this._recipientMetaAddress,
317
+ recipientAddress: this._recipientAddress,
318
+ privacy: this._privacy,
319
+ viewingKey: this._viewingKey,
320
+ sourceChain: this._sourceChain!,
321
+ destinationChain: this._destinationChain,
322
+ purpose: this._purpose,
323
+ memo: this._memo,
324
+ ttl: this._ttl,
325
+ }
326
+
327
+ return createShieldedPayment(params, {
328
+ senderAddress: this._senderAddress,
329
+ proofProvider: this._proofProvider,
330
+ })
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Create a shielded payment
336
+ *
337
+ * @param params - Payment creation parameters
338
+ * @param options - Optional configuration
339
+ * @returns Promise resolving to the shielded payment
340
+ */
341
+ export async function createShieldedPayment(
342
+ params: CreatePaymentParams,
343
+ options?: CreatePaymentOptions,
344
+ ): Promise<ShieldedPayment> {
345
+ const {
346
+ token,
347
+ amount,
348
+ recipientMetaAddress,
349
+ recipientAddress,
350
+ privacy,
351
+ viewingKey,
352
+ sourceChain,
353
+ destinationChain,
354
+ purpose,
355
+ memo,
356
+ ttl = 3600,
357
+ } = params
358
+
359
+ const { senderAddress, proofProvider } = options ?? {}
360
+
361
+ // Resolve token if it's a symbol
362
+ let resolvedToken: Asset
363
+ if (typeof token === 'string') {
364
+ if (!isStablecoin(token)) {
365
+ throw new ValidationError(
366
+ `unknown stablecoin: ${token}`,
367
+ 'token',
368
+ { received: token },
369
+ ErrorCode.INVALID_INPUT
370
+ )
371
+ }
372
+ const asset = getStablecoin(token, sourceChain)
373
+ if (!asset) {
374
+ throw new ValidationError(
375
+ `${token} is not available on ${sourceChain}`,
376
+ 'token',
377
+ { symbol: token, chain: sourceChain },
378
+ ErrorCode.INVALID_INPUT
379
+ )
380
+ }
381
+ resolvedToken = asset
382
+ } else {
383
+ resolvedToken = token
384
+ }
385
+
386
+ // Validate privacy requirements
387
+ if (privacy !== PrivacyLevel.TRANSPARENT && !recipientMetaAddress) {
388
+ throw new ValidationError(
389
+ 'recipientMetaAddress is required for shielded/compliant privacy modes',
390
+ 'recipientMetaAddress',
391
+ undefined,
392
+ ErrorCode.MISSING_REQUIRED
393
+ )
394
+ }
395
+ if (privacy === PrivacyLevel.TRANSPARENT && !recipientAddress) {
396
+ throw new ValidationError(
397
+ 'recipientAddress is required for transparent mode',
398
+ 'recipientAddress',
399
+ undefined,
400
+ ErrorCode.MISSING_REQUIRED
401
+ )
402
+ }
403
+ if (privacy === PrivacyLevel.COMPLIANT && !viewingKey) {
404
+ throw new ValidationError(
405
+ 'viewingKey is required for compliant mode',
406
+ 'viewingKey',
407
+ undefined,
408
+ ErrorCode.MISSING_REQUIRED
409
+ )
410
+ }
411
+
412
+ // Generate payment ID
413
+ const paymentId = generateIntentId()
414
+
415
+ // Calculate viewing key hash
416
+ let viewingKeyHash: Hash | undefined
417
+ if (viewingKey) {
418
+ const keyHex = viewingKey.startsWith('0x') ? viewingKey.slice(2) : viewingKey
419
+ const keyBytes = hexToBytes(keyHex)
420
+ viewingKeyHash = `0x${bytesToHex(sha256(keyBytes))}` as Hash
421
+ }
422
+
423
+ // Get privacy config
424
+ const privacyConfig = getPrivacyConfig(
425
+ privacy,
426
+ viewingKey ? { key: viewingKey, path: 'm/0', hash: viewingKeyHash! } : undefined,
427
+ )
428
+
429
+ const now = Math.floor(Date.now() / 1000)
430
+
431
+ // Create the base payment object
432
+ const payment: ShieldedPayment = {
433
+ paymentId,
434
+ version: SIP_VERSION,
435
+ privacyLevel: privacy,
436
+ createdAt: now,
437
+ expiry: now + ttl,
438
+ token: resolvedToken,
439
+ amount,
440
+ sourceChain,
441
+ destinationChain: destinationChain ?? sourceChain,
442
+ purpose,
443
+ viewingKeyHash,
444
+ }
445
+
446
+ // Handle privacy-specific fields
447
+ if (privacy !== PrivacyLevel.TRANSPARENT && recipientMetaAddress) {
448
+ // Generate stealth address
449
+ const metaAddress = decodeStealthMetaAddress(recipientMetaAddress)
450
+ const { stealthAddress } = generateStealthAddress(metaAddress)
451
+ payment.recipientStealth = stealthAddress
452
+
453
+ // Create commitments
454
+ payment.amountCommitment = createCommitment(amount)
455
+ payment.senderCommitment = createCommitment(
456
+ BigInt(senderAddress ? hash(senderAddress).slice(2, 18) : '0')
457
+ )
458
+
459
+ // Encrypt memo if provided
460
+ if (memo && viewingKey) {
461
+ payment.encryptedMemo = encryptMemo(memo, viewingKey)
462
+ } else {
463
+ payment.memo = memo
464
+ }
465
+ } else {
466
+ // Transparent mode
467
+ payment.recipientAddress = recipientAddress
468
+ payment.memo = memo
469
+ }
470
+
471
+ // Generate proofs if provider available
472
+ if (privacy !== PrivacyLevel.TRANSPARENT && proofProvider?.isReady) {
473
+ const hexToUint8 = (hex: HexString): Uint8Array => {
474
+ const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex
475
+ return hexToBytes(cleanHex)
476
+ }
477
+
478
+ // Generate funding proof
479
+ const fundingResult = await proofProvider.generateFundingProof({
480
+ balance: amount,
481
+ minimumRequired: amount,
482
+ blindingFactor: hexToUint8(payment.amountCommitment!.blindingFactor as HexString),
483
+ assetId: resolvedToken.symbol,
484
+ userAddress: senderAddress ?? '0x0',
485
+ ownershipSignature: new Uint8Array(64),
486
+ })
487
+ payment.fundingProof = fundingResult.proof
488
+
489
+ // Generate validity proof (as authorization)
490
+ const validityResult = await proofProvider.generateValidityProof({
491
+ intentHash: hash(paymentId) as HexString,
492
+ senderAddress: senderAddress ?? '0x0',
493
+ senderBlinding: hexToUint8(payment.senderCommitment!.blindingFactor as HexString),
494
+ senderSecret: new Uint8Array(32),
495
+ authorizationSignature: new Uint8Array(64),
496
+ nonce: new Uint8Array(32),
497
+ timestamp: now,
498
+ expiry: now + ttl,
499
+ })
500
+ payment.authorizationProof = validityResult.proof
501
+ }
502
+
503
+ return payment
504
+ }
505
+
506
+ /**
507
+ * Encrypt a memo using the viewing key
508
+ */
509
+ function encryptMemo(memo: string, viewingKey: HexString): HexString {
510
+ const keyHex = viewingKey.startsWith('0x') ? viewingKey.slice(2) : viewingKey
511
+ const keyBytes = hexToBytes(keyHex)
512
+
513
+ // Derive encryption key using HKDF
514
+ const encKey = hkdf(sha256, keyBytes, new Uint8Array(0), new Uint8Array(0), 32)
515
+
516
+ try {
517
+ // Generate nonce
518
+ const nonce = randomBytes(24)
519
+
520
+ // Encrypt
521
+ const cipher = xchacha20poly1305(encKey, nonce)
522
+ const plaintext = new TextEncoder().encode(memo)
523
+ const ciphertext = cipher.encrypt(plaintext)
524
+
525
+ // Concatenate nonce + ciphertext
526
+ const result = new Uint8Array(nonce.length + ciphertext.length)
527
+ result.set(nonce)
528
+ result.set(ciphertext, nonce.length)
529
+
530
+ return `0x${bytesToHex(result)}` as HexString
531
+ } finally {
532
+ secureWipe(keyBytes)
533
+ secureWipe(encKey)
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Decrypt a memo using the viewing key
539
+ */
540
+ export function decryptMemo(encryptedMemo: HexString, viewingKey: HexString): string {
541
+ const keyHex = viewingKey.startsWith('0x') ? viewingKey.slice(2) : viewingKey
542
+ const keyBytes = hexToBytes(keyHex)
543
+
544
+ // Derive encryption key using HKDF
545
+ const encKey = hkdf(sha256, keyBytes, new Uint8Array(0), new Uint8Array(0), 32)
546
+
547
+ try {
548
+ // Parse encrypted data
549
+ const dataHex = encryptedMemo.startsWith('0x') ? encryptedMemo.slice(2) : encryptedMemo
550
+ const data = hexToBytes(dataHex)
551
+
552
+ // Extract nonce and ciphertext
553
+ const nonce = data.slice(0, 24)
554
+ const ciphertext = data.slice(24)
555
+
556
+ // Decrypt
557
+ const cipher = xchacha20poly1305(encKey, nonce)
558
+ const plaintext = cipher.decrypt(ciphertext)
559
+
560
+ return new TextDecoder().decode(plaintext)
561
+ } finally {
562
+ secureWipe(keyBytes)
563
+ secureWipe(encKey)
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Track a payment's status
569
+ */
570
+ export function trackPayment(payment: ShieldedPayment): TrackedPayment {
571
+ return {
572
+ ...payment,
573
+ status: PaymentStatus.DRAFT,
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Check if a payment has expired
579
+ */
580
+ export function isPaymentExpired(payment: ShieldedPayment): boolean {
581
+ return Math.floor(Date.now() / 1000) > payment.expiry
582
+ }
583
+
584
+ /**
585
+ * Get time remaining until payment expires (in seconds)
586
+ */
587
+ export function getPaymentTimeRemaining(payment: ShieldedPayment): number {
588
+ const remaining = payment.expiry - Math.floor(Date.now() / 1000)
589
+ return Math.max(0, remaining)
590
+ }
591
+
592
+ /**
593
+ * Serialize a payment to JSON
594
+ */
595
+ export function serializePayment(payment: ShieldedPayment): string {
596
+ return JSON.stringify(payment, (_, value) =>
597
+ typeof value === 'bigint' ? value.toString() : value
598
+ )
599
+ }
600
+
601
+ /**
602
+ * Deserialize a payment from JSON
603
+ */
604
+ export function deserializePayment(json: string): ShieldedPayment {
605
+ return JSON.parse(json, (key, value) => {
606
+ if (typeof value === 'string' && /^\d+$/.test(value) && key === 'amount') {
607
+ return BigInt(value)
608
+ }
609
+ return value
610
+ })
611
+ }
612
+
613
+ /**
614
+ * Get a human-readable summary of the payment
615
+ */
616
+ export function getPaymentSummary(payment: ShieldedPayment): string {
617
+ const privacy = payment.privacyLevel.toUpperCase()
618
+ const amount = Number(payment.amount) / (10 ** payment.token.decimals)
619
+ const token = payment.token.symbol
620
+ const expiry = new Date(payment.expiry * 1000).toISOString()
621
+
622
+ return `[${privacy}] Payment ${payment.paymentId.slice(0, 12)}... ${amount} ${token} (expires: ${expiry})`
623
+ }