@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,641 @@
1
+ /**
2
+ * Zcash Shielded Transaction Service
3
+ *
4
+ * High-level service for managing Zcash shielded transactions,
5
+ * providing integration with SIP Protocol privacy levels.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * const service = new ZcashShieldedService({
10
+ * rpcConfig: { username: 'user', password: 'pass', testnet: true },
11
+ * })
12
+ *
13
+ * // Initialize and create account
14
+ * await service.initialize()
15
+ *
16
+ * // Send shielded transaction
17
+ * const result = await service.sendShielded({
18
+ * to: recipientAddress,
19
+ * amount: 1.5,
20
+ * memo: 'Payment for services',
21
+ * privacyLevel: PrivacyLevel.SHIELDED,
22
+ * })
23
+ *
24
+ * // Check incoming transactions
25
+ * const received = await service.getReceivedNotes()
26
+ * ```
27
+ */
28
+
29
+ import {
30
+ type ZcashConfig,
31
+ type ZcashUnspentNote,
32
+ type ZcashAccountBalance,
33
+ type ZcashOperation,
34
+ type ZcashPrivacyPolicy,
35
+ type ZcashAddressInfo,
36
+ PrivacyLevel,
37
+ } from '@sip-protocol/types'
38
+ import { ZcashRPCClient, ZcashRPCError } from './rpc-client'
39
+ import { ValidationError, IntentError, ErrorCode } from '../errors'
40
+
41
+ // ─── Types ─────────────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Configuration for ZcashShieldedService
45
+ */
46
+ export interface ZcashShieldedServiceConfig {
47
+ /** RPC client configuration */
48
+ rpcConfig: ZcashConfig
49
+ /** Default account to use (default: 0) */
50
+ defaultAccount?: number
51
+ /** Default minimum confirmations (default: 1) */
52
+ defaultMinConf?: number
53
+ /** Poll interval for operations in ms (default: 1000) */
54
+ operationPollInterval?: number
55
+ /** Operation timeout in ms (default: 300000 = 5 min) */
56
+ operationTimeout?: number
57
+ }
58
+
59
+ /**
60
+ * Shielded send parameters
61
+ */
62
+ export interface ShieldedSendParams {
63
+ /** Recipient address (shielded or unified) */
64
+ to: string
65
+ /** Amount in ZEC */
66
+ amount: number
67
+ /** Optional memo (max 512 bytes) */
68
+ memo?: string
69
+ /** SIP privacy level */
70
+ privacyLevel?: PrivacyLevel
71
+ /** Source address (uses default if not specified) */
72
+ from?: string
73
+ /** Minimum confirmations for inputs */
74
+ minConf?: number
75
+ /** Custom fee (uses ZIP-317 default if not specified) */
76
+ fee?: number
77
+ }
78
+
79
+ /**
80
+ * Result of a shielded send operation
81
+ */
82
+ export interface ShieldedSendResult {
83
+ /** Transaction ID */
84
+ txid: string
85
+ /** Operation ID (for tracking) */
86
+ operationId: string
87
+ /** Amount sent (excluding fee) */
88
+ amount: number
89
+ /** Fee paid */
90
+ fee: number
91
+ /** Recipient address */
92
+ to: string
93
+ /** Sender address */
94
+ from: string
95
+ /** Timestamp */
96
+ timestamp: number
97
+ }
98
+
99
+ /**
100
+ * Received note information
101
+ */
102
+ export interface ReceivedNote {
103
+ /** Transaction ID */
104
+ txid: string
105
+ /** Amount received */
106
+ amount: number
107
+ /** Memo content (if any) */
108
+ memo?: string
109
+ /** Number of confirmations */
110
+ confirmations: number
111
+ /** Whether spendable */
112
+ spendable: boolean
113
+ /** Pool type (sapling/orchard) */
114
+ pool: 'sapling' | 'orchard'
115
+ /** Receiving address */
116
+ address: string
117
+ /** Whether this is change */
118
+ isChange: boolean
119
+ }
120
+
121
+ /**
122
+ * Shielded balance summary
123
+ */
124
+ export interface ShieldedBalance {
125
+ /** Total confirmed balance in ZEC */
126
+ confirmed: number
127
+ /** Total unconfirmed balance in ZEC */
128
+ unconfirmed: number
129
+ /** Balance by pool */
130
+ pools: {
131
+ transparent: number
132
+ sapling: number
133
+ orchard: number
134
+ }
135
+ /** Number of spendable notes */
136
+ spendableNotes: number
137
+ }
138
+
139
+ /**
140
+ * Viewing key export result
141
+ */
142
+ export interface ExportedViewingKey {
143
+ /** The viewing key */
144
+ key: string
145
+ /** Associated address */
146
+ address: string
147
+ /** Account number */
148
+ account: number
149
+ /** Creation timestamp */
150
+ exportedAt: number
151
+ }
152
+
153
+ // ─── Service Implementation ────────────────────────────────────────────────────
154
+
155
+ /**
156
+ * Zcash Shielded Transaction Service
157
+ *
158
+ * Provides high-level operations for Zcash shielded transactions
159
+ * with SIP Protocol integration.
160
+ */
161
+ export class ZcashShieldedService {
162
+ private readonly client: ZcashRPCClient
163
+ private readonly config: Required<Omit<ZcashShieldedServiceConfig, 'rpcConfig'>>
164
+ private initialized: boolean = false
165
+ private accountAddress: string | null = null
166
+ private account: number = 0
167
+
168
+ constructor(config: ZcashShieldedServiceConfig) {
169
+ this.client = new ZcashRPCClient(config.rpcConfig)
170
+ this.config = {
171
+ defaultAccount: config.defaultAccount ?? 0,
172
+ defaultMinConf: config.defaultMinConf ?? 1,
173
+ operationPollInterval: config.operationPollInterval ?? 1000,
174
+ operationTimeout: config.operationTimeout ?? 300000,
175
+ }
176
+ this.account = this.config.defaultAccount
177
+ }
178
+
179
+ // ─── Initialization ──────────────────────────────────────────────────────────
180
+
181
+ /**
182
+ * Initialize the service
183
+ *
184
+ * Creates an account if needed and retrieves the default address.
185
+ */
186
+ async initialize(): Promise<void> {
187
+ if (this.initialized) return
188
+
189
+ // Verify connection
190
+ await this.client.getBlockCount()
191
+
192
+ // Get or create account address
193
+ try {
194
+ const addressResult = await this.client.getAddressForAccount(this.account, ['sapling', 'orchard'])
195
+ this.accountAddress = addressResult.address
196
+ } catch (error) {
197
+ // Account might not exist, create it
198
+ if (error instanceof ZcashRPCError) {
199
+ const newAccount = await this.client.createAccount()
200
+ this.account = newAccount.account
201
+ const addressResult = await this.client.getAddressForAccount(this.account, ['sapling', 'orchard'])
202
+ this.accountAddress = addressResult.address
203
+ } else {
204
+ throw error
205
+ }
206
+ }
207
+
208
+ this.initialized = true
209
+ }
210
+
211
+ /**
212
+ * Ensure the service is initialized
213
+ */
214
+ private ensureInitialized(): void {
215
+ if (!this.initialized) {
216
+ throw new IntentError(
217
+ 'ZcashShieldedService not initialized. Call initialize() first.',
218
+ ErrorCode.INTENT_INVALID_STATE,
219
+ )
220
+ }
221
+ }
222
+
223
+ // ─── Address Operations ──────────────────────────────────────────────────────
224
+
225
+ /**
226
+ * Get the default shielded address
227
+ */
228
+ getAddress(): string {
229
+ this.ensureInitialized()
230
+ return this.accountAddress!
231
+ }
232
+
233
+ /**
234
+ * Generate a new diversified address for the account
235
+ *
236
+ * Each address is unlinkable but controlled by the same account.
237
+ */
238
+ async generateNewAddress(): Promise<string> {
239
+ this.ensureInitialized()
240
+ const result = await this.client.getAddressForAccount(this.account, ['sapling', 'orchard'])
241
+ return result.address
242
+ }
243
+
244
+ /**
245
+ * Validate an address
246
+ */
247
+ async validateAddress(address: string): Promise<ZcashAddressInfo> {
248
+ return this.client.validateAddress(address)
249
+ }
250
+
251
+ /**
252
+ * Check if an address is a shielded address
253
+ */
254
+ async isShieldedAddress(address: string): Promise<boolean> {
255
+ const info = await this.client.validateAddress(address)
256
+ if (!info.isvalid) return false
257
+ return info.address_type === 'sapling' ||
258
+ info.address_type === 'orchard' ||
259
+ info.address_type === 'unified'
260
+ }
261
+
262
+ // ─── Balance Operations ──────────────────────────────────────────────────────
263
+
264
+ /**
265
+ * Get shielded balance summary
266
+ */
267
+ async getBalance(minConf?: number): Promise<ShieldedBalance> {
268
+ this.ensureInitialized()
269
+
270
+ const accountBalance = await this.client.getAccountBalance(
271
+ this.account,
272
+ minConf ?? this.config.defaultMinConf,
273
+ )
274
+
275
+ // Get unspent notes for spendable count
276
+ const notes = await this.client.listUnspent(minConf ?? this.config.defaultMinConf)
277
+ const spendableNotes = notes.filter((n) => n.spendable).length
278
+
279
+ // Convert zatoshis to ZEC
280
+ const toZec = (zat: number | undefined) => (zat ?? 0) / 100_000_000
281
+
282
+ const transparent = toZec(accountBalance.pools.transparent?.valueZat)
283
+ const sapling = toZec(accountBalance.pools.sapling?.valueZat)
284
+ const orchard = toZec(accountBalance.pools.orchard?.valueZat)
285
+
286
+ return {
287
+ confirmed: transparent + sapling + orchard,
288
+ unconfirmed: 0, // Would need separate RPC call
289
+ pools: {
290
+ transparent,
291
+ sapling,
292
+ orchard,
293
+ },
294
+ spendableNotes,
295
+ }
296
+ }
297
+
298
+ // ─── Send Operations ─────────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Send a shielded transaction
302
+ *
303
+ * @param params - Send parameters
304
+ * @returns Send result with txid
305
+ */
306
+ async sendShielded(params: ShieldedSendParams): Promise<ShieldedSendResult> {
307
+ this.ensureInitialized()
308
+
309
+ // Validate recipient address
310
+ const recipientInfo = await this.client.validateAddress(params.to)
311
+ if (!recipientInfo.isvalid) {
312
+ throw new ValidationError(
313
+ `Invalid recipient address: ${params.to}`,
314
+ 'to',
315
+ undefined,
316
+ ErrorCode.INVALID_ADDRESS,
317
+ )
318
+ }
319
+
320
+ // Validate amount
321
+ if (params.amount <= 0) {
322
+ throw new ValidationError(
323
+ 'Amount must be positive',
324
+ 'amount',
325
+ { received: params.amount },
326
+ ErrorCode.INVALID_AMOUNT,
327
+ )
328
+ }
329
+
330
+ // Determine privacy policy based on SIP privacy level
331
+ const privacyPolicy = this.mapPrivacyLevelToPolicy(params.privacyLevel)
332
+
333
+ // Prepare memo (convert to hex if string)
334
+ let memoHex: string | undefined
335
+ if (params.memo) {
336
+ memoHex = Buffer.from(params.memo, 'utf-8').toString('hex')
337
+ }
338
+
339
+ // Determine source address
340
+ const fromAddress = params.from ?? this.accountAddress!
341
+
342
+ // Send transaction
343
+ const operationId = await this.client.sendShielded({
344
+ fromAddress,
345
+ recipients: [
346
+ {
347
+ address: params.to,
348
+ amount: params.amount,
349
+ memo: memoHex,
350
+ },
351
+ ],
352
+ minConf: params.minConf ?? this.config.defaultMinConf,
353
+ fee: params.fee,
354
+ privacyPolicy,
355
+ })
356
+
357
+ // Wait for operation to complete
358
+ const operation = await this.client.waitForOperation(
359
+ operationId,
360
+ this.config.operationPollInterval,
361
+ this.config.operationTimeout,
362
+ )
363
+
364
+ if (!operation.result?.txid) {
365
+ throw new IntentError(
366
+ 'Transaction completed but no txid returned',
367
+ ErrorCode.INTENT_FAILED,
368
+ { context: { operationId } },
369
+ )
370
+ }
371
+
372
+ return {
373
+ txid: operation.result.txid,
374
+ operationId,
375
+ amount: params.amount,
376
+ fee: params.fee ?? 0, // TODO: Get actual fee from operation
377
+ to: params.to,
378
+ from: fromAddress,
379
+ timestamp: Math.floor(Date.now() / 1000),
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Send shielded transaction with SIP integration
385
+ *
386
+ * Higher-level method that handles privacy level mapping.
387
+ */
388
+ async sendWithPrivacy(
389
+ to: string,
390
+ amount: number,
391
+ privacyLevel: PrivacyLevel,
392
+ memo?: string,
393
+ ): Promise<ShieldedSendResult> {
394
+ // For transparent mode, we could use t-addr but for now require shielded
395
+ if (privacyLevel === PrivacyLevel.TRANSPARENT) {
396
+ throw new ValidationError(
397
+ 'Transparent mode not supported for Zcash shielded service. Use standard RPC client.',
398
+ 'privacyLevel',
399
+ { received: privacyLevel },
400
+ ErrorCode.INVALID_PRIVACY_LEVEL,
401
+ )
402
+ }
403
+
404
+ return this.sendShielded({
405
+ to,
406
+ amount,
407
+ memo,
408
+ privacyLevel,
409
+ })
410
+ }
411
+
412
+ // ─── Receive Operations ──────────────────────────────────────────────────────
413
+
414
+ /**
415
+ * Get received notes (incoming shielded transactions)
416
+ *
417
+ * @param minConf - Minimum confirmations
418
+ * @param onlySpendable - Only return spendable notes
419
+ */
420
+ async getReceivedNotes(minConf?: number, onlySpendable: boolean = false): Promise<ReceivedNote[]> {
421
+ this.ensureInitialized()
422
+
423
+ const notes = await this.client.listUnspent(
424
+ minConf ?? this.config.defaultMinConf,
425
+ 9999999,
426
+ false,
427
+ )
428
+
429
+ return notes
430
+ .filter((note) => !onlySpendable || note.spendable)
431
+ .filter((note) => note.pool === 'sapling' || note.pool === 'orchard')
432
+ .map((note) => this.mapNoteToReceived(note))
433
+ }
434
+
435
+ /**
436
+ * Get pending (unconfirmed) incoming transactions
437
+ */
438
+ async getPendingNotes(): Promise<ReceivedNote[]> {
439
+ return this.getReceivedNotes(0)
440
+ .then((notes) => notes.filter((n) => n.confirmations === 0))
441
+ }
442
+
443
+ /**
444
+ * Wait for incoming note with specific criteria
445
+ *
446
+ * @param predicate - Function to match the expected note
447
+ * @param timeout - Timeout in ms
448
+ * @param pollInterval - Poll interval in ms
449
+ */
450
+ async waitForNote(
451
+ predicate: (note: ReceivedNote) => boolean,
452
+ timeout: number = 300000,
453
+ pollInterval: number = 5000,
454
+ ): Promise<ReceivedNote> {
455
+ const startTime = Date.now()
456
+
457
+ while (Date.now() - startTime < timeout) {
458
+ const notes = await this.getReceivedNotes(0)
459
+ const match = notes.find(predicate)
460
+
461
+ if (match) {
462
+ return match
463
+ }
464
+
465
+ await this.delay(pollInterval)
466
+ }
467
+
468
+ throw new IntentError(
469
+ 'Timed out waiting for incoming note',
470
+ ErrorCode.NETWORK_TIMEOUT,
471
+ )
472
+ }
473
+
474
+ // ─── Viewing Key Operations ──────────────────────────────────────────────────
475
+
476
+ /**
477
+ * Export viewing key for an address
478
+ *
479
+ * The viewing key allows monitoring incoming transactions
480
+ * without spending capability.
481
+ */
482
+ async exportViewingKey(address?: string): Promise<ExportedViewingKey> {
483
+ this.ensureInitialized()
484
+ const targetAddress = address ?? this.accountAddress!
485
+
486
+ const key = await this.client.exportViewingKey(targetAddress)
487
+
488
+ return {
489
+ key,
490
+ address: targetAddress,
491
+ account: this.account,
492
+ exportedAt: Math.floor(Date.now() / 1000),
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Import viewing key for monitoring
498
+ *
499
+ * Allows monitoring transactions to an address without spending.
500
+ */
501
+ async importViewingKey(
502
+ viewingKey: string,
503
+ rescan: 'yes' | 'no' | 'whenkeyisnew' = 'whenkeyisnew',
504
+ startHeight?: number,
505
+ ): Promise<void> {
506
+ await this.client.importViewingKey(viewingKey, rescan, startHeight)
507
+ }
508
+
509
+ /**
510
+ * Export viewing key for compliance/audit
511
+ *
512
+ * Specifically for SIP COMPLIANT privacy level.
513
+ */
514
+ async exportForCompliance(): Promise<{
515
+ viewingKey: ExportedViewingKey
516
+ privacyLevel: PrivacyLevel
517
+ disclaimer: string
518
+ }> {
519
+ const viewingKey = await this.exportViewingKey()
520
+
521
+ return {
522
+ viewingKey,
523
+ privacyLevel: PrivacyLevel.COMPLIANT,
524
+ disclaimer:
525
+ 'This viewing key provides read-only access to transaction history. ' +
526
+ 'It cannot be used to spend funds. Share only with authorized auditors.',
527
+ }
528
+ }
529
+
530
+ // ─── Operation Tracking ──────────────────────────────────────────────────────
531
+
532
+ /**
533
+ * Get status of an operation
534
+ */
535
+ async getOperationStatus(operationId: string): Promise<ZcashOperation | null> {
536
+ const [operation] = await this.client.getOperationStatus([operationId])
537
+ return operation ?? null
538
+ }
539
+
540
+ /**
541
+ * List all pending operations
542
+ */
543
+ async listPendingOperations(): Promise<ZcashOperation[]> {
544
+ const executing = await this.client.getOperationStatus()
545
+ return executing.filter((op) => op.status === 'executing' || op.status === 'queued')
546
+ }
547
+
548
+ // ─── Blockchain Info ─────────────────────────────────────────────────────────
549
+
550
+ /**
551
+ * Get current block height
552
+ */
553
+ async getBlockHeight(): Promise<number> {
554
+ return this.client.getBlockCount()
555
+ }
556
+
557
+ /**
558
+ * Check if connected to testnet
559
+ */
560
+ isTestnet(): boolean {
561
+ return this.client.isTestnet
562
+ }
563
+
564
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
565
+
566
+ /**
567
+ * Map SIP privacy level to Zcash privacy policy
568
+ */
569
+ private mapPrivacyLevelToPolicy(level?: PrivacyLevel): ZcashPrivacyPolicy {
570
+ switch (level) {
571
+ case PrivacyLevel.TRANSPARENT:
572
+ return 'NoPrivacy'
573
+ case PrivacyLevel.SHIELDED:
574
+ return 'FullPrivacy'
575
+ case PrivacyLevel.COMPLIANT:
576
+ // Compliant mode uses full privacy but exports viewing key separately
577
+ return 'FullPrivacy'
578
+ default:
579
+ return 'FullPrivacy'
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Map RPC unspent note to ReceivedNote
585
+ */
586
+ private mapNoteToReceived(note: ZcashUnspentNote): ReceivedNote {
587
+ // Decode memo if present
588
+ let memo: string | undefined
589
+ if (note.memoStr) {
590
+ memo = note.memoStr
591
+ } else if (note.memo && note.memo !== '00' && !note.memo.match(/^f+$/i)) {
592
+ // Try to decode non-empty, non-padding memo
593
+ try {
594
+ memo = Buffer.from(note.memo, 'hex').toString('utf-8').replace(/\0+$/, '')
595
+ if (!memo || memo.length === 0) memo = undefined
596
+ } catch {
597
+ // Invalid UTF-8, leave as undefined
598
+ }
599
+ }
600
+
601
+ return {
602
+ txid: note.txid,
603
+ amount: note.amount,
604
+ memo,
605
+ confirmations: note.confirmations,
606
+ spendable: note.spendable,
607
+ pool: note.pool as 'sapling' | 'orchard',
608
+ address: note.address,
609
+ isChange: note.change,
610
+ }
611
+ }
612
+
613
+ private delay(ms: number): Promise<void> {
614
+ return new Promise((resolve) => setTimeout(resolve, ms))
615
+ }
616
+
617
+ // ─── Getters ─────────────────────────────────────────────────────────────────
618
+
619
+ /**
620
+ * Get underlying RPC client for advanced operations
621
+ */
622
+ get rpcClient(): ZcashRPCClient {
623
+ return this.client
624
+ }
625
+
626
+ /**
627
+ * Get current account number
628
+ */
629
+ get currentAccount(): number {
630
+ return this.account
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Create a Zcash shielded service instance
636
+ */
637
+ export function createZcashShieldedService(
638
+ config: ZcashShieldedServiceConfig,
639
+ ): ZcashShieldedService {
640
+ return new ZcashShieldedService(config)
641
+ }