@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,623 @@
1
+ /**
2
+ * Zcash RPC Client
3
+ *
4
+ * HTTP client for interacting with zcashd node via JSON-RPC.
5
+ * Supports both mainnet and testnet with automatic retry logic.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * const client = new ZcashRPCClient({
10
+ * username: 'rpcuser',
11
+ * password: 'rpcpassword',
12
+ * testnet: true,
13
+ * })
14
+ *
15
+ * // Create new account and get address
16
+ * const { account } = await client.createAccount()
17
+ * const { address } = await client.getAddressForAccount(account)
18
+ *
19
+ * // Send shielded transaction
20
+ * const opId = await client.sendShielded({
21
+ * fromAddress: address,
22
+ * recipients: [{ address: recipientAddr, amount: 0.1 }],
23
+ * })
24
+ *
25
+ * // Wait for completion
26
+ * const result = await client.waitForOperation(opId)
27
+ * ```
28
+ */
29
+
30
+ import {
31
+ type ZcashConfig,
32
+ type ZcashAddressInfo,
33
+ type ZcashNewAccount,
34
+ type ZcashAccountAddress,
35
+ type ZcashAccountBalance,
36
+ type ZcashReceiverType,
37
+ type ZcashUnspentNote,
38
+ type ZcashShieldedSendParams,
39
+ type ZcashOperation,
40
+ type ZcashBlockHeader,
41
+ type ZcashBlock,
42
+ type ZcashRPCRequest,
43
+ type ZcashRPCResponse,
44
+ type ZcashRPCError as ZcashRPCErrorType,
45
+ type ZcashBlockchainInfo,
46
+ type ZcashNetworkInfo,
47
+ ZcashErrorCode,
48
+ } from '@sip-protocol/types'
49
+ import { NetworkError, ErrorCode } from '../errors'
50
+
51
+ // ─── Default Configuration ─────────────────────────────────────────────────────
52
+
53
+ const DEFAULT_CONFIG: Required<Omit<ZcashConfig, 'username' | 'password'>> = {
54
+ host: '127.0.0.1',
55
+ port: 8232,
56
+ testnet: false,
57
+ timeout: 30000,
58
+ retries: 3,
59
+ retryDelay: 1000,
60
+ }
61
+
62
+ // ─── Error Classes ─────────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Error thrown when Zcash RPC call fails
66
+ */
67
+ export class ZcashRPCError extends Error {
68
+ constructor(
69
+ message: string,
70
+ public readonly code: number,
71
+ public readonly data?: unknown,
72
+ ) {
73
+ super(message)
74
+ this.name = 'ZcashRPCError'
75
+ }
76
+
77
+ /**
78
+ * Check if error is due to insufficient funds
79
+ */
80
+ isInsufficientFunds(): boolean {
81
+ return this.code === ZcashErrorCode.WALLET_INSUFFICIENT_FUNDS
82
+ }
83
+
84
+ /**
85
+ * Check if error is due to invalid address
86
+ */
87
+ isInvalidAddress(): boolean {
88
+ return this.code === ZcashErrorCode.INVALID_ADDRESS_OR_KEY
89
+ }
90
+
91
+ /**
92
+ * Check if error is due to wallet being locked
93
+ */
94
+ isWalletLocked(): boolean {
95
+ return this.code === ZcashErrorCode.WALLET_UNLOCK_NEEDED
96
+ }
97
+ }
98
+
99
+ // ─── RPC Client ────────────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Zcash RPC Client
103
+ *
104
+ * Provides type-safe access to zcashd JSON-RPC API with automatic
105
+ * retry logic and proper error handling.
106
+ *
107
+ * @security IMPORTANT: Always use HTTPS in production environments.
108
+ * This client uses HTTP Basic Authentication which transmits credentials
109
+ * in base64-encoded cleartext. Without TLS/HTTPS, credentials and all
110
+ * RPC data are vulnerable to network sniffing and man-in-the-middle attacks.
111
+ *
112
+ * Production configuration should use:
113
+ * - HTTPS endpoint (e.g., https://your-node.com:8232)
114
+ * - Valid TLS certificates
115
+ * - Secure credential storage
116
+ * - Network-level access controls
117
+ */
118
+ export class ZcashRPCClient {
119
+ private readonly config: Required<ZcashConfig>
120
+ private readonly baseUrl: string
121
+ private requestId: number = 0
122
+
123
+ constructor(config: ZcashConfig) {
124
+ // Use testnet port if testnet is enabled and no custom port provided
125
+ const defaultPort = config.testnet ? 18232 : DEFAULT_CONFIG.port
126
+
127
+ this.config = {
128
+ host: config.host ?? DEFAULT_CONFIG.host,
129
+ port: config.port ?? defaultPort,
130
+ username: config.username,
131
+ password: config.password,
132
+ testnet: config.testnet ?? DEFAULT_CONFIG.testnet,
133
+ timeout: config.timeout ?? DEFAULT_CONFIG.timeout,
134
+ retries: config.retries ?? DEFAULT_CONFIG.retries,
135
+ retryDelay: config.retryDelay ?? DEFAULT_CONFIG.retryDelay,
136
+ }
137
+
138
+ this.baseUrl = `http://${this.config.host}:${this.config.port}`
139
+ }
140
+
141
+ // ─── Address Operations ────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Validate a Zcash address
145
+ *
146
+ * @param address - Address to validate (t-addr, z-addr, or unified)
147
+ * @returns Address validation info
148
+ */
149
+ async validateAddress(address: string): Promise<ZcashAddressInfo> {
150
+ return this.call<ZcashAddressInfo>('z_validateaddress', [address])
151
+ }
152
+
153
+ /**
154
+ * Create a new HD account
155
+ *
156
+ * @returns New account number
157
+ */
158
+ async createAccount(): Promise<ZcashNewAccount> {
159
+ return this.call<ZcashNewAccount>('z_getnewaccount', [])
160
+ }
161
+
162
+ /**
163
+ * Get or derive an address for an account
164
+ *
165
+ * @param account - Account number
166
+ * @param receiverTypes - Optional receiver types (default: best shielded + p2pkh)
167
+ * @param diversifierIndex - Optional specific diversifier index
168
+ * @returns Account address info
169
+ */
170
+ async getAddressForAccount(
171
+ account: number,
172
+ receiverTypes?: ZcashReceiverType[],
173
+ diversifierIndex?: number,
174
+ ): Promise<ZcashAccountAddress> {
175
+ const params: unknown[] = [account]
176
+ if (receiverTypes !== undefined) {
177
+ params.push(receiverTypes)
178
+ if (diversifierIndex !== undefined) {
179
+ params.push(diversifierIndex)
180
+ }
181
+ }
182
+ return this.call<ZcashAccountAddress>('z_getaddressforaccount', params)
183
+ }
184
+
185
+ /**
186
+ * Generate a new shielded address (DEPRECATED)
187
+ *
188
+ * @deprecated Use createAccount() and getAddressForAccount() instead
189
+ * @param type - Address type ('sapling' or 'sprout')
190
+ * @returns New shielded address
191
+ */
192
+ async generateShieldedAddress(type: 'sapling' | 'sprout' = 'sapling'): Promise<string> {
193
+ console.warn(
194
+ 'generateShieldedAddress() is deprecated and will be removed in v0.2.0. ' +
195
+ 'Use createAccount() and getAddressForAccount() instead.'
196
+ )
197
+ return this.call<string>('z_getnewaddress', [type])
198
+ }
199
+
200
+ /**
201
+ * List all shielded addresses in the wallet
202
+ *
203
+ * @returns Array of shielded addresses
204
+ */
205
+ async listAddresses(): Promise<string[]> {
206
+ return this.call<string[]>('z_listaddresses', [])
207
+ }
208
+
209
+ // ─── Balance Operations ────────────────────────────────────────────────────
210
+
211
+ /**
212
+ * Get balance for an account
213
+ *
214
+ * @param account - Account number
215
+ * @param minConf - Minimum confirmations (default: 1)
216
+ * @returns Account balance by pool
217
+ */
218
+ async getAccountBalance(account: number, minConf: number = 1): Promise<ZcashAccountBalance> {
219
+ return this.call<ZcashAccountBalance>('z_getbalanceforaccount', [account, minConf])
220
+ }
221
+
222
+ /**
223
+ * Get balance for an address (DEPRECATED)
224
+ *
225
+ * @deprecated Use getAccountBalance() instead
226
+ * @param address - Address to check
227
+ * @param minConf - Minimum confirmations
228
+ * @returns Balance in ZEC
229
+ */
230
+ async getBalance(address: string, minConf: number = 1): Promise<number> {
231
+ return this.call<number>('z_getbalance', [address, minConf])
232
+ }
233
+
234
+ /**
235
+ * Get total wallet balance
236
+ *
237
+ * @param minConf - Minimum confirmations
238
+ * @returns Total balances (transparent, private, total)
239
+ */
240
+ async getTotalBalance(minConf: number = 1): Promise<{
241
+ transparent: string
242
+ private: string
243
+ total: string
244
+ }> {
245
+ return this.call('z_gettotalbalance', [minConf])
246
+ }
247
+
248
+ // ─── UTXO Operations ───────────────────────────────────────────────────────
249
+
250
+ /**
251
+ * List unspent shielded notes
252
+ *
253
+ * @param minConf - Minimum confirmations (default: 1)
254
+ * @param maxConf - Maximum confirmations (default: 9999999)
255
+ * @param includeWatchonly - Include watchonly addresses
256
+ * @param addresses - Filter by addresses
257
+ * @returns Array of unspent notes
258
+ */
259
+ async listUnspent(
260
+ minConf: number = 1,
261
+ maxConf: number = 9999999,
262
+ includeWatchonly: boolean = false,
263
+ addresses?: string[],
264
+ ): Promise<ZcashUnspentNote[]> {
265
+ const params: unknown[] = [minConf, maxConf, includeWatchonly]
266
+ if (addresses) {
267
+ params.push(addresses)
268
+ }
269
+ return this.call<ZcashUnspentNote[]>('z_listunspent', params)
270
+ }
271
+
272
+ // ─── Transaction Operations ────────────────────────────────────────────────
273
+
274
+ /**
275
+ * Send a shielded transaction
276
+ *
277
+ * @param params - Send parameters
278
+ * @returns Operation ID for tracking
279
+ */
280
+ async sendShielded(params: ZcashShieldedSendParams): Promise<string> {
281
+ const amounts = params.recipients.map((r) => ({
282
+ address: r.address,
283
+ amount: r.amount,
284
+ ...(r.memo && { memo: r.memo }),
285
+ }))
286
+
287
+ const rpcParams: unknown[] = [params.fromAddress, amounts]
288
+
289
+ if (params.minConf !== undefined) {
290
+ rpcParams.push(params.minConf)
291
+ if (params.fee !== undefined) {
292
+ rpcParams.push(params.fee)
293
+ if (params.privacyPolicy !== undefined) {
294
+ rpcParams.push(params.privacyPolicy)
295
+ }
296
+ }
297
+ }
298
+
299
+ return this.call<string>('z_sendmany', rpcParams)
300
+ }
301
+
302
+ /**
303
+ * Shield coinbase UTXOs to a shielded address
304
+ *
305
+ * @param fromAddress - Transparent address with coinbase
306
+ * @param toAddress - Shielded destination
307
+ * @param fee - Optional fee
308
+ * @param limit - Max UTXOs to shield
309
+ * @returns Operation ID
310
+ */
311
+ async shieldCoinbase(
312
+ fromAddress: string,
313
+ toAddress: string,
314
+ fee?: number,
315
+ limit?: number,
316
+ ): Promise<{ operationid: string; shieldingUTXOs: number; shieldingValue: number }> {
317
+ const params: unknown[] = [fromAddress, toAddress]
318
+ if (fee !== undefined) params.push(fee)
319
+ if (limit !== undefined) params.push(limit)
320
+ return this.call('z_shieldcoinbase', params)
321
+ }
322
+
323
+ // ─── Operation Management ──────────────────────────────────────────────────
324
+
325
+ /**
326
+ * Get status of async operations
327
+ *
328
+ * @param operationIds - Optional specific operation IDs
329
+ * @returns Array of operation statuses
330
+ */
331
+ async getOperationStatus(operationIds?: string[]): Promise<ZcashOperation[]> {
332
+ return this.call<ZcashOperation[]>('z_getoperationstatus', operationIds ? [operationIds] : [])
333
+ }
334
+
335
+ /**
336
+ * Get and remove completed operation results
337
+ *
338
+ * @param operationIds - Optional specific operation IDs
339
+ * @returns Array of operation results
340
+ */
341
+ async getOperationResult(operationIds?: string[]): Promise<ZcashOperation[]> {
342
+ return this.call<ZcashOperation[]>('z_getoperationresult', operationIds ? [operationIds] : [])
343
+ }
344
+
345
+ /**
346
+ * List all operation IDs
347
+ *
348
+ * @param status - Optional filter by status
349
+ * @returns Array of operation IDs
350
+ */
351
+ async listOperationIds(status?: string): Promise<string[]> {
352
+ return this.call<string[]>('z_listoperationids', status ? [status] : [])
353
+ }
354
+
355
+ /**
356
+ * Wait for an operation to complete
357
+ *
358
+ * @param operationId - Operation ID to wait for
359
+ * @param pollInterval - Poll interval in ms (default: 1000)
360
+ * @param timeout - Max wait time in ms (default: 300000 = 5 min)
361
+ * @returns Completed operation
362
+ * @throws ZcashRPCError if operation fails or times out
363
+ */
364
+ async waitForOperation(
365
+ operationId: string,
366
+ pollInterval: number = 1000,
367
+ timeout: number = 300000,
368
+ ): Promise<ZcashOperation> {
369
+ const startTime = Date.now()
370
+
371
+ while (Date.now() - startTime < timeout) {
372
+ const [operation] = await this.getOperationStatus([operationId])
373
+
374
+ if (!operation) {
375
+ throw new ZcashRPCError(`Operation ${operationId} not found`, -1)
376
+ }
377
+
378
+ if (operation.status === 'success') {
379
+ return operation
380
+ }
381
+
382
+ if (operation.status === 'failed') {
383
+ throw new ZcashRPCError(
384
+ operation.error?.message ?? 'Operation failed',
385
+ operation.error?.code ?? -1,
386
+ )
387
+ }
388
+
389
+ if (operation.status === 'cancelled') {
390
+ throw new ZcashRPCError('Operation was cancelled', -1)
391
+ }
392
+
393
+ // Still executing or queued, wait and retry
394
+ await this.delay(pollInterval)
395
+ }
396
+
397
+ throw new ZcashRPCError(`Operation ${operationId} timed out after ${timeout}ms`, -1)
398
+ }
399
+
400
+ // ─── Blockchain Operations ─────────────────────────────────────────────────
401
+
402
+ /**
403
+ * Get current block count
404
+ *
405
+ * @returns Current block height
406
+ */
407
+ async getBlockCount(): Promise<number> {
408
+ return this.call<number>('getblockcount', [])
409
+ }
410
+
411
+ /**
412
+ * Get block hash at height
413
+ *
414
+ * @param height - Block height
415
+ * @returns Block hash
416
+ */
417
+ async getBlockHash(height: number): Promise<string> {
418
+ return this.call<string>('getblockhash', [height])
419
+ }
420
+
421
+ /**
422
+ * Get block header
423
+ *
424
+ * @param hashOrHeight - Block hash or height
425
+ * @returns Block header
426
+ */
427
+ async getBlockHeader(hashOrHeight: string | number): Promise<ZcashBlockHeader> {
428
+ const hash =
429
+ typeof hashOrHeight === 'number' ? await this.getBlockHash(hashOrHeight) : hashOrHeight
430
+ return this.call<ZcashBlockHeader>('getblockheader', [hash, true])
431
+ }
432
+
433
+ /**
434
+ * Get full block data
435
+ *
436
+ * @param hashOrHeight - Block hash or height
437
+ * @returns Block data
438
+ */
439
+ async getBlock(hashOrHeight: string | number): Promise<ZcashBlock> {
440
+ const hash =
441
+ typeof hashOrHeight === 'number' ? await this.getBlockHash(hashOrHeight) : hashOrHeight
442
+ return this.call<ZcashBlock>('getblock', [hash, 1])
443
+ }
444
+
445
+ /**
446
+ * Get blockchain info
447
+ *
448
+ * @returns Blockchain information
449
+ */
450
+ async getBlockchainInfo(): Promise<ZcashBlockchainInfo> {
451
+ return this.call<ZcashBlockchainInfo>('getblockchaininfo', [])
452
+ }
453
+
454
+ /**
455
+ * Get network info
456
+ *
457
+ * @returns Network information
458
+ */
459
+ async getNetworkInfo(): Promise<ZcashNetworkInfo> {
460
+ return this.call<ZcashNetworkInfo>('getnetworkinfo', [])
461
+ }
462
+
463
+ // ─── Key Management ────────────────────────────────────────────────────────
464
+
465
+ /**
466
+ * Export viewing key for address
467
+ *
468
+ * @param address - Shielded address
469
+ * @returns Viewing key
470
+ */
471
+ async exportViewingKey(address: string): Promise<string> {
472
+ return this.call<string>('z_exportviewingkey', [address])
473
+ }
474
+
475
+ /**
476
+ * Import viewing key
477
+ *
478
+ * @param viewingKey - The viewing key to import
479
+ * @param rescan - Rescan the wallet (default: whenkeyisnew)
480
+ * @param startHeight - Start height for rescan
481
+ */
482
+ async importViewingKey(
483
+ viewingKey: string,
484
+ rescan: 'yes' | 'no' | 'whenkeyisnew' = 'whenkeyisnew',
485
+ startHeight?: number,
486
+ ): Promise<void> {
487
+ const params: unknown[] = [viewingKey, rescan]
488
+ if (startHeight !== undefined) params.push(startHeight)
489
+ await this.call<null>('z_importviewingkey', params)
490
+ }
491
+
492
+ // ─── Low-Level RPC ─────────────────────────────────────────────────────────
493
+
494
+ /**
495
+ * Make a raw RPC call
496
+ *
497
+ * @param method - RPC method name
498
+ * @param params - Method parameters
499
+ * @returns RPC response result
500
+ */
501
+ async call<T>(method: string, params: unknown[] = []): Promise<T> {
502
+ const request: ZcashRPCRequest = {
503
+ jsonrpc: '1.0',
504
+ id: ++this.requestId,
505
+ method,
506
+ params,
507
+ }
508
+
509
+ let lastError: Error | null = null
510
+
511
+ for (let attempt = 0; attempt <= this.config.retries; attempt++) {
512
+ try {
513
+ const response = await this.executeRequest<T>(request)
514
+
515
+ if (response.error) {
516
+ throw new ZcashRPCError(response.error.message, response.error.code, response.error.data)
517
+ }
518
+
519
+ return response.result as T
520
+ } catch (error) {
521
+ lastError = error as Error
522
+
523
+ // Don't retry on RPC errors (only on network errors)
524
+ if (error instanceof ZcashRPCError) {
525
+ throw error
526
+ }
527
+
528
+ // Wait before retry (except on last attempt)
529
+ if (attempt < this.config.retries) {
530
+ await this.delay(this.config.retryDelay * (attempt + 1))
531
+ }
532
+ }
533
+ }
534
+
535
+ throw new NetworkError(
536
+ `Zcash RPC call failed after ${this.config.retries + 1} attempts: ${lastError?.message}`,
537
+ ErrorCode.NETWORK_FAILED,
538
+ { cause: lastError ?? undefined },
539
+ )
540
+ }
541
+
542
+ /**
543
+ * Execute HTTP request to RPC endpoint
544
+ */
545
+ private async executeRequest<T>(request: ZcashRPCRequest): Promise<ZcashRPCResponse<T>> {
546
+ const controller = new AbortController()
547
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout)
548
+
549
+ try {
550
+ const response = await fetch(this.baseUrl, {
551
+ method: 'POST',
552
+ headers: {
553
+ 'Content-Type': 'application/json',
554
+ Authorization: `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`,
555
+ },
556
+ body: JSON.stringify(request),
557
+ signal: controller.signal,
558
+ })
559
+
560
+ if (!response.ok) {
561
+ throw new Error(`HTTP error: ${response.status} ${response.statusText}`)
562
+ }
563
+
564
+ return (await response.json()) as ZcashRPCResponse<T>
565
+ } finally {
566
+ clearTimeout(timeoutId)
567
+ }
568
+ }
569
+
570
+ private delay(ms: number): Promise<void> {
571
+ return new Promise((resolve) => setTimeout(resolve, ms))
572
+ }
573
+
574
+ // ─── Getters ───────────────────────────────────────────────────────────────
575
+
576
+ /**
577
+ * Check if client is configured for testnet
578
+ */
579
+ get isTestnet(): boolean {
580
+ return this.config.testnet
581
+ }
582
+
583
+ /**
584
+ * Get the RPC endpoint URL
585
+ */
586
+ get endpoint(): string {
587
+ return this.baseUrl
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Create a Zcash RPC client
593
+ *
594
+ * @param config - Client configuration
595
+ * @returns ZcashRPCClient instance
596
+ *
597
+ * @security IMPORTANT: Always use HTTPS in production environments.
598
+ * HTTP Basic Auth transmits credentials in cleartext without TLS/HTTPS.
599
+ * Configure your zcashd node with TLS certificates and use https:// URLs.
600
+ *
601
+ * @example
602
+ * ```typescript
603
+ * // ✅ Production (HTTPS)
604
+ * const client = createZcashClient({
605
+ * host: 'https://your-node.com',
606
+ * port: 8232,
607
+ * username: process.env.ZCASH_RPC_USER,
608
+ * password: process.env.ZCASH_RPC_PASS,
609
+ * })
610
+ *
611
+ * // ⚠️ Development only (HTTP)
612
+ * const testClient = createZcashClient({
613
+ * host: '127.0.0.1',
614
+ * port: 18232,
615
+ * username: 'test',
616
+ * password: 'test',
617
+ * testnet: true,
618
+ * })
619
+ * ```
620
+ */
621
+ export function createZcashClient(config: ZcashConfig): ZcashRPCClient {
622
+ return new ZcashRPCClient(config)
623
+ }