@velumdotcash/sdk 2.1.0 → 2.2.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/README.md CHANGED
@@ -5,13 +5,13 @@
5
5
 
6
6
  TypeScript SDK for private payments on Solana using Zero-Knowledge proofs. Deposit, withdraw, and transfer shielded funds with ZK-SNARKs.
7
7
 
8
- ## Installation
8
+ ## 📦 Installation
9
9
 
10
10
  ```bash
11
11
  npm install @velumdotcash/sdk
12
12
  ```
13
13
 
14
- ## Quick Start
14
+ ## 🚀 Quick Start
15
15
 
16
16
  ### Browser (with wallet adapter)
17
17
 
@@ -45,7 +45,7 @@ const sdk = new PrivacyCash({
45
45
  });
46
46
  ```
47
47
 
48
- ## Deposits
48
+ ## 💰 Deposits
49
49
 
50
50
  ```typescript
51
51
  // Deposit SOL to your shielded account
@@ -68,7 +68,7 @@ await sdk.deposit({
68
68
  });
69
69
  ```
70
70
 
71
- ## Withdrawals
71
+ ## 💸 Withdrawals
72
72
 
73
73
  ```typescript
74
74
  // Withdraw SOL to any address
@@ -91,7 +91,7 @@ await sdk.withdrawSPL({
91
91
  });
92
92
  ```
93
93
 
94
- ## Private Balance
94
+ ## 🔒 Private Balance
95
95
 
96
96
  ```typescript
97
97
  const sol = await sdk.getPrivateBalance();
@@ -99,7 +99,7 @@ const usdc = await sdk.getPrivateBalanceUSDC();
99
99
  const spl = await sdk.getPrivateBalanceSpl(mintAddress);
100
100
  ```
101
101
 
102
- ## Key Derivation
102
+ ## 🔑 Key Derivation
103
103
 
104
104
  ```typescript
105
105
  // Get your shielded public keys (share these to receive payments)
@@ -107,7 +107,7 @@ const encryptionKey = sdk.getAsymmetricPublicKey(); // Uint8Array (X25519)
107
107
  const utxoPubkey = await sdk.getShieldedPublicKey(); // string (BN254)
108
108
  ```
109
109
 
110
- ## How It Works
110
+ ## ⚙️ How It Works
111
111
 
112
112
  1. **Key derivation**: A wallet signature deterministically derives two keypairs — BN254 (UTXO ownership) and X25519 (note encryption)
113
113
  2. **Deposit**: Funds enter a shielded pool via a ZK-SNARK that proves validity without revealing the amount or recipient
@@ -116,7 +116,7 @@ const utxoPubkey = await sdk.getShieldedPublicKey(); // string (BN254)
116
116
 
117
117
  The result: no onchain link between sender and receiver.
118
118
 
119
- ## Circuit Files
119
+ ## 📁 Circuit Files
120
120
 
121
121
  The SDK requires ZK circuit files (`circuit.wasm` ~3MB, `circuit.zkey` ~16MB) for proof generation.
122
122
 
@@ -131,7 +131,7 @@ const sdk = new PrivacyCash({
131
131
 
132
132
  **Node.js**: Point to a local directory containing the circuit files.
133
133
 
134
- ## Error Types
134
+ ## ⚠️ Error Types
135
135
 
136
136
  ```typescript
137
137
  import {
@@ -142,12 +142,12 @@ import {
142
142
  } from "@velumdotcash/sdk";
143
143
  ```
144
144
 
145
- ## Related
145
+ ## 🔗 Related
146
146
 
147
147
  - [`@velumdotcash/api`](https://www.npmjs.com/package/@velumdotcash/api) — Server-side REST client for paylinks and transactions
148
148
  - [Developer Guide](https://velum.cash/docs/developer-guide) — Full integration documentation
149
149
  - [How It Works](https://velum.cash/docs/how-it-works) — Cryptographic architecture
150
150
 
151
- ## License
151
+ ## 📄 License
152
152
 
153
153
  [MIT](./LICENSE)
package/dist/config.d.ts CHANGED
@@ -5,5 +5,16 @@ type Config = {
5
5
  usdc_withdraw_rent_fee: number;
6
6
  rent_fees: any;
7
7
  };
8
+ /**
9
+ * Get config value with automatic cache refresh
10
+ */
8
11
  export declare function getConfig<K extends keyof Config>(key: K): Promise<Config[K]>;
12
+ /**
13
+ * Force refresh the config cache
14
+ */
15
+ export declare function refreshConfig(): Promise<void>;
16
+ /**
17
+ * Clear the config cache (useful for testing)
18
+ */
19
+ export declare function clearConfigCache(): void;
9
20
  export {};
package/dist/config.js CHANGED
@@ -1,12 +1,42 @@
1
1
  import { RELAYER_API_URL } from "./utils/constants.js";
2
+ // Cache TTL in milliseconds (5 minutes)
3
+ const CONFIG_CACHE_TTL_MS = 5 * 60 * 1000;
2
4
  let config;
5
+ let configFetchedAt;
6
+ /**
7
+ * Check if cached config is still valid
8
+ */
9
+ function isCacheValid() {
10
+ if (!config || !configFetchedAt)
11
+ return false;
12
+ return Date.now() - configFetchedAt < CONFIG_CACHE_TTL_MS;
13
+ }
14
+ /**
15
+ * Get config value with automatic cache refresh
16
+ */
3
17
  export async function getConfig(key) {
4
- if (!config) {
18
+ if (!isCacheValid()) {
5
19
  const res = await fetch(RELAYER_API_URL + '/config');
6
20
  config = await res.json();
21
+ configFetchedAt = Date.now();
7
22
  }
8
23
  if (typeof config[key] == 'undefined') {
9
24
  throw new Error(`can not get ${key} from ${RELAYER_API_URL}/config`);
10
25
  }
11
26
  return config[key];
12
27
  }
28
+ /**
29
+ * Force refresh the config cache
30
+ */
31
+ export async function refreshConfig() {
32
+ const res = await fetch(RELAYER_API_URL + '/config');
33
+ config = await res.json();
34
+ configFetchedAt = Date.now();
35
+ }
36
+ /**
37
+ * Clear the config cache (useful for testing)
38
+ */
39
+ export function clearConfigCache() {
40
+ config = undefined;
41
+ configFetchedAt = undefined;
42
+ }
package/dist/deposit.js CHANGED
@@ -11,6 +11,7 @@ import { getUtxos } from "./getUtxos.js";
11
11
  import { FIELD_SIZE, FEE_RECIPIENT, VELUM_FEE_WALLET, VELUM_FEE_BPS, MERKLE_TREE_DEPTH, RELAYER_API_URL, PROGRAM_ID, ALT_ADDRESS, } from "./utils/constants.js";
12
12
  import { useExistingALT } from "./utils/address_lookup_table.js";
13
13
  import { logger } from "./utils/logger.js";
14
+ import { sleepWithBackoff } from "./utils/retry.js";
14
15
  // Function to relay pre-signed deposit transaction to indexer backend
15
16
  async function relayDepositToIndexer(signedTransaction, publicKey, referrer) {
16
17
  try {
@@ -46,6 +47,27 @@ async function relayDepositToIndexer(signedTransaction, publicKey, referrer) {
46
47
  }
47
48
  }
48
49
  export async function deposit({ lightWasm, storage, keyBasePath, publicKey, connection, amount_in_lamports, encryptionService, transactionSigner, referrer, signer, recipientUtxoPublicKey, recipientEncryptionKey, }) {
50
+ // Validate recipientUtxoPublicKey if provided (must be within BN254 field)
51
+ if (recipientUtxoPublicKey !== undefined) {
52
+ const pubkeyBN = BN.isBN(recipientUtxoPublicKey)
53
+ ? recipientUtxoPublicKey
54
+ : new BN(recipientUtxoPublicKey);
55
+ if (pubkeyBN.isZero()) {
56
+ throw new Error("Invalid recipientUtxoPublicKey: cannot be zero");
57
+ }
58
+ if (pubkeyBN.isNeg()) {
59
+ throw new Error("Invalid recipientUtxoPublicKey: cannot be negative");
60
+ }
61
+ if (pubkeyBN.gte(FIELD_SIZE)) {
62
+ throw new Error("Invalid recipientUtxoPublicKey: exceeds BN254 field size");
63
+ }
64
+ }
65
+ // Validate recipientEncryptionKey if provided (must be exactly 32 bytes for X25519)
66
+ if (recipientEncryptionKey !== undefined) {
67
+ if (!recipientEncryptionKey || recipientEncryptionKey.length !== 32) {
68
+ throw new Error(`Invalid recipientEncryptionKey: X25519 keys must be exactly 32 bytes, got ${recipientEncryptionKey?.length ?? 0}`);
69
+ }
70
+ }
49
71
  // check limit
50
72
  let limitAmount = await checkDepositLimit(connection);
51
73
  if (limitAmount && amount_in_lamports > limitAmount * LAMPORTS_PER_SOL) {
@@ -392,24 +414,29 @@ export async function deposit({ lightWasm, storage, keyBasePath, publicKey, conn
392
414
  logger.debug(`Transaction link: https://orbmarkets.io/tx/${signature}`);
393
415
  logger.info("Waiting for transaction confirmation...");
394
416
  let retryTimes = 0;
395
- let itv = 2;
417
+ const maxRetries = 10;
396
418
  const encryptedOutputStr = Buffer.from(encryptedOutput1).toString("hex");
397
- let start = Date.now();
419
+ const start = Date.now();
398
420
  while (true) {
399
421
  logger.info("Confirming transaction..");
400
422
  logger.debug(`retryTimes: ${retryTimes}`);
401
- await new Promise((resolve) => setTimeout(resolve, itv * 1000));
423
+ // Use exponential backoff: 2s, 4s, 8s, 16s, up to 30s max
424
+ const delayMs = await sleepWithBackoff(retryTimes, {
425
+ baseDelayMs: 2000,
426
+ maxDelayMs: 30000,
427
+ });
428
+ logger.debug(`Waited ${delayMs}ms before retry`);
402
429
  logger.debug("Fetching updated tree state...");
403
- let res = await fetch(RELAYER_API_URL + "/utxos/check/" + encryptedOutputStr);
404
- let resJson = await res.json();
430
+ const res = await fetch(RELAYER_API_URL + "/utxos/check/" + encryptedOutputStr);
431
+ const resJson = await res.json();
405
432
  if (resJson.exists) {
406
433
  logger.debug(`Top up successfully in ${((Date.now() - start) / 1000).toFixed(2)} seconds!`);
407
434
  return { tx: signature };
408
435
  }
409
- if (retryTimes >= 10) {
410
- throw new TransactionTimeoutError(`Transaction confirmation timeout after ${retryTimes * 3} seconds`, signature || undefined);
411
- }
412
436
  retryTimes++;
437
+ if (retryTimes >= maxRetries) {
438
+ throw new TransactionTimeoutError(`Transaction confirmation timeout after ${((Date.now() - start) / 1000).toFixed(0)} seconds`, signature || undefined);
439
+ }
413
440
  }
414
441
  }
415
442
  async function checkDepositLimit(connection) {
@@ -10,6 +10,7 @@ import { getUtxosSPL } from "./getUtxosSPL.js";
10
10
  import { FIELD_SIZE, FEE_RECIPIENT, MERKLE_TREE_DEPTH, RELAYER_API_URL, PROGRAM_ID, ALT_ADDRESS, tokens, VELUM_FEE_WALLET, VELUM_FEE_BPS, } from "./utils/constants.js";
11
11
  import { useExistingALT, } from "./utils/address_lookup_table.js";
12
12
  import { logger } from "./utils/logger.js";
13
+ import { sleepWithBackoff } from "./utils/retry.js";
13
14
  import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync, getAccount, createTransferInstruction, } from "@solana/spl-token";
14
15
  // Function to relay pre-signed deposit transaction to indexer backend
15
16
  async function relayDepositToIndexer({ signedTransaction, publicKey, referrer, mintAddress, }) {
@@ -70,6 +71,27 @@ export async function depositSPL({ lightWasm, storage, keyBasePath, publicKey, c
70
71
  if (!signer) {
71
72
  signer = publicKey;
72
73
  }
74
+ // Validate recipientUtxoPublicKey if provided (must be within BN254 field)
75
+ if (recipientUtxoPublicKey !== undefined) {
76
+ const pubkeyBN = BN.isBN(recipientUtxoPublicKey)
77
+ ? recipientUtxoPublicKey
78
+ : new BN(recipientUtxoPublicKey);
79
+ if (pubkeyBN.isZero()) {
80
+ throw new Error("Invalid recipientUtxoPublicKey: cannot be zero");
81
+ }
82
+ if (pubkeyBN.isNeg()) {
83
+ throw new Error("Invalid recipientUtxoPublicKey: cannot be negative");
84
+ }
85
+ if (pubkeyBN.gte(FIELD_SIZE)) {
86
+ throw new Error("Invalid recipientUtxoPublicKey: exceeds BN254 field size");
87
+ }
88
+ }
89
+ // Validate recipientEncryptionKey if provided (must be exactly 32 bytes for X25519)
90
+ if (recipientEncryptionKey !== undefined) {
91
+ if (!recipientEncryptionKey || recipientEncryptionKey.length !== 32) {
92
+ throw new Error(`Invalid recipientEncryptionKey: X25519 keys must be exactly 32 bytes, got ${recipientEncryptionKey?.length ?? 0}`);
93
+ }
94
+ }
73
95
  // let mintInfo = await getMint(connection, token.pubkey)
74
96
  // let units_per_token = 10 ** mintInfo.decimals
75
97
  let recipient = new PublicKey("AWexibGxNFKTa1b5R5MN4PJr9HWnWRwf8EW9g8cLx3dM");
@@ -424,6 +446,11 @@ export async function depositSPL({ lightWasm, storage, keyBasePath, publicKey, c
424
446
  instructions: [modifyComputeUnits, velumFeeInstruction, depositInstruction],
425
447
  }).compileToV0Message([lookupTableAccount.value]);
426
448
  let versionedTransaction = new VersionedTransaction(messageV0);
449
+ // Debug: measure exact transaction size before signing
450
+ const unsignedBytes = versionedTransaction.message.serialize().length;
451
+ const totalEstimate = unsignedBytes + 1 + 64; // 1 byte sig count + 64 byte signature
452
+ logger.debug(`Message size: ${unsignedBytes} bytes, estimated tx size: ${totalEstimate}/1232 bytes`);
453
+ logger.debug(`Static accounts: ${messageV0.staticAccountKeys.length}, ALT writable: ${messageV0.addressTableLookups?.[0]?.writableIndexes?.length ?? 0}, ALT readonly: ${messageV0.addressTableLookups?.[0]?.readonlyIndexes?.length ?? 0}`);
427
454
  // sign tx
428
455
  versionedTransaction = await transactionSigner(versionedTransaction);
429
456
  logger.debug("Transaction signed by user");
@@ -442,29 +469,34 @@ export async function depositSPL({ lightWasm, storage, keyBasePath, publicKey, c
442
469
  logger.debug(`Transaction link: https://orbmarkets.io/tx/${signature}`);
443
470
  logger.info("Waiting for transaction confirmation...");
444
471
  let retryTimes = 0;
445
- let itv = 2;
472
+ const maxRetries = 10;
446
473
  const encryptedOutputStr = Buffer.from(encryptedOutput1).toString("hex");
447
- let start = Date.now();
474
+ const start = Date.now();
448
475
  while (true) {
449
476
  logger.info("Confirming transaction..");
450
477
  logger.debug(`retryTimes: ${retryTimes}`);
451
- await new Promise((resolve) => setTimeout(resolve, itv * 1000));
478
+ // Use exponential backoff: 2s, 4s, 8s, 16s, up to 30s max
479
+ const delayMs = await sleepWithBackoff(retryTimes, {
480
+ baseDelayMs: 2000,
481
+ maxDelayMs: 30000,
482
+ });
483
+ logger.debug(`Waited ${delayMs}ms before retry`);
452
484
  logger.debug("Fetching updated tree state...");
453
- let url = RELAYER_API_URL +
485
+ const url = RELAYER_API_URL +
454
486
  "/utxos/check/" +
455
487
  encryptedOutputStr +
456
488
  "?token=" +
457
489
  token.name;
458
- let res = await fetch(url);
459
- let resJson = await res.json();
490
+ const res = await fetch(url);
491
+ const resJson = await res.json();
460
492
  if (resJson.exists) {
461
493
  logger.debug(`Top up successfully in ${((Date.now() - start) / 1000).toFixed(2)} seconds!`);
462
494
  return { tx: signature };
463
495
  }
464
- if (retryTimes >= 10) {
496
+ retryTimes++;
497
+ if (retryTimes >= maxRetries) {
465
498
  throw new Error("Refresh the page to see latest balance.");
466
499
  }
467
- retryTimes++;
468
500
  }
469
501
  }
470
502
  async function checkDepositLimit(connection, treeAccount, token) {
@@ -29,7 +29,7 @@ export declare class Utxo {
29
29
  *
30
30
  * Generate a new keypair for each UTXO
31
31
  */
32
- keypair, publicKey, blinding, // Use fixed value for consistency instead of randomBN()
32
+ keypair, publicKey, blinding, // Cryptographically secure random blinding
33
33
  index, mintAddress, // Default to Solana native SOL mint address,
34
34
  version }: {
35
35
  lightWasm: hasher.LightWasm;
@@ -5,10 +5,22 @@
5
5
  * Based on: https://github.com/tornadocash/tornado-nova
6
6
  */
7
7
  import BN from 'bn.js';
8
+ import nacl from 'tweetnacl';
8
9
  import { Keypair } from './keypair.js';
9
10
  import { ethers } from 'ethers';
10
11
  import { getMintAddressField } from '../utils/utils.js';
11
12
  import { PublicKey } from '@solana/web3.js';
13
+ import { FIELD_SIZE } from '../utils/constants.js';
14
+ /**
15
+ * Generate a cryptographically secure random blinding factor.
16
+ * Uses nacl.randomBytes() instead of Math.random() for security.
17
+ */
18
+ function generateSecureBlinding() {
19
+ const randomBytes = nacl.randomBytes(32);
20
+ const randomBN = new BN(randomBytes);
21
+ // Reduce modulo FIELD_SIZE to ensure it's within the valid range
22
+ return randomBN.mod(FIELD_SIZE);
23
+ }
12
24
  /**
13
25
  * Simplified Utxo class inspired by Tornado Cash Nova
14
26
  * Based on: https://github.com/tornadocash/tornado-nova/blob/f9264eeffe48bf5e04e19d8086ee6ec58cdf0d9e/src/utxo.js
@@ -31,7 +43,7 @@ export class Utxo {
31
43
  *
32
44
  * Generate a new keypair for each UTXO
33
45
  */
34
- keypair, publicKey, blinding = new BN(Math.floor(Math.random() * 1000000000)), // Use fixed value for consistency instead of randomBN()
46
+ keypair, publicKey, blinding = generateSecureBlinding(), // Cryptographically secure random blinding
35
47
  index = 0, mintAddress = '11111111111111111111111111111112', // Default to Solana native SOL mint address,
36
48
  version = 'v2' }) {
37
49
  this.amount = new BN(amount.toString());
@@ -76,7 +76,7 @@ export declare function installDebugCommands(): void;
76
76
  * 2. PRIVACY_CASH_DEBUG environment variable is set to 'true' or '1'
77
77
  * 3. window.PRIVACY_CASH_DEBUG is set to true, 'true', or '1'
78
78
  * 4. Running in development mode (NODE_ENV=development or localhost)
79
- * 5. URL contains ?privacy_cash_debug=true or ?privacy_cash_debug=1 (production feature)
79
+ * 5. URL contains ?privacy_cash_debug=true or ?privacy_cash_debug=1 (localhost only)
80
80
  */
81
81
  export declare function isDebugEnabled(): boolean;
82
82
  /**
@@ -30,13 +30,19 @@ function isDevelopmentMode() {
30
30
  return false;
31
31
  }
32
32
  /**
33
- * Check URL parameters for debug enablement (production feature)
33
+ * Check URL parameters for debug enablement (development only)
34
34
  * Enables via ?privacy_cash_debug=1 or ?privacy_cash_debug=true
35
+ * SECURITY: Only works on localhost to prevent information leakage in production
35
36
  */
36
37
  function checkUrlParamDebugEnabled() {
37
38
  if (typeof window === 'undefined' || typeof location === 'undefined') {
38
39
  return false;
39
40
  }
41
+ // SECURITY: Only allow URL parameter debugging on localhost
42
+ // This prevents attackers from enabling debug mode in production via URL manipulation
43
+ if (location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') {
44
+ return false;
45
+ }
40
46
  // Only check once to avoid repeated URL parsing
41
47
  if (urlParamChecked) {
42
48
  return false;
@@ -162,7 +168,7 @@ export function installDebugCommands() {
162
168
  console.log(`[PRIVACY-CASH-DEBUG] Status: ${enabled ? 'ENABLED' : 'DISABLED'}`);
163
169
  console.log(`[PRIVACY-CASH-DEBUG] Verbose: ${verbose ? 'ENABLED' : 'DISABLED'}`);
164
170
  console.log(`[PRIVACY-CASH-DEBUG] Mode: ${mode}`);
165
- console.log('[PRIVACY-CASH-DEBUG] To enable: window.privacyCashDebug.enable() or add ?privacy_cash_debug=1 to URL');
171
+ console.log('[PRIVACY-CASH-DEBUG] To enable: window.privacyCashDebug.enable() (or ?privacy_cash_debug=1 on localhost)');
166
172
  console.log('[PRIVACY-CASH-DEBUG] For verbose: window.privacyCashDebug.verbose() or set PRIVACY_CASH_VERBOSE=1');
167
173
  }
168
174
  };
@@ -179,7 +185,7 @@ if (typeof window !== 'undefined') {
179
185
  * 2. PRIVACY_CASH_DEBUG environment variable is set to 'true' or '1'
180
186
  * 3. window.PRIVACY_CASH_DEBUG is set to true, 'true', or '1'
181
187
  * 4. Running in development mode (NODE_ENV=development or localhost)
182
- * 5. URL contains ?privacy_cash_debug=true or ?privacy_cash_debug=1 (production feature)
188
+ * 5. URL contains ?privacy_cash_debug=true or ?privacy_cash_debug=1 (localhost only)
183
189
  */
184
190
  export function isDebugEnabled() {
185
191
  return debugEnabled || checkEnvDebugEnabled() || isDevelopmentMode() || checkUrlParamDebugEnabled();
@@ -20,6 +20,8 @@ export declare class EncryptionService {
20
20
  static readonly ENCRYPTION_VERSION_V2: Buffer<ArrayBuffer>;
21
21
  static readonly ENCRYPTION_VERSION_V3: Buffer<ArrayBuffer>;
22
22
  static readonly SIGNATURE_SCHEMA_VERSION: Buffer<ArrayBuffer>;
23
+ static readonly COMPACT_V2_TAG = 194;
24
+ static readonly COMPACT_V3_TAG = 195;
23
25
  static readonly RECIPIENT_ID_LENGTH = 8;
24
26
  private encryptionKeyV1;
25
27
  private encryptionKeyV2;
@@ -84,7 +86,7 @@ export declare class EncryptionService {
84
86
  /**
85
87
  * Encrypt data using Asymmetric Encryption (X25519 + XSalsa20-Poly1305)
86
88
  * @param data The data to encrypt
87
- * @param recipientPublicKey The recipient's X25519 Public Key
89
+ * @param recipientPublicKey The recipient's X25519 Public Key (must be exactly 32 bytes)
88
90
  */
89
91
  encryptAsymmetric(data: Buffer | string, recipientPublicKey: Uint8Array): Buffer;
90
92
  /**
@@ -103,14 +105,27 @@ export declare class EncryptionService {
103
105
  */
104
106
  resetEncryptionKey(): void;
105
107
  /**
106
- * Encrypt a UTXO using a compact pipe-delimited format
107
- * Always uses V2 encryption format. The UTXO's version property is used only for key derivation.
108
+ * Encrypt a UTXO using compact binary encoding and compact encryption headers.
109
+ * Plaintext: 77-byte binary [0x01][amount:8][blinding:32][index:4][mint:32]
110
+ * Encryption: compact V2 (0xC2 tag, AES-GCM) or compact V3 (0xC3 tag, NaCl Box)
108
111
  * @param utxo The UTXO to encrypt (includes version property)
109
112
  * @param recipientEncryptionKey Optional recipient X25519 public key for asymmetric encryption
110
113
  * @returns The encrypted UTXO data as a Buffer
111
114
  * @throws Error if the V2 encryption key has not been set
112
115
  */
116
+ private static readonly BINARY_UTXO_FLAG;
117
+ private static readonly BINARY_UTXO_LENGTH;
113
118
  encryptUtxo(utxo: Utxo, recipientEncryptionKey?: Uint8Array): Buffer;
119
+ /**
120
+ * Compact V2 symmetric encryption: [0xC2][recipientIdHash(8)][IV(12)][authTag(16)][ciphertext]
121
+ * Saves 8 bytes vs standard V2 format by using 1-byte tag instead of 8-byte version + 1-byte schema
122
+ */
123
+ private encryptUtxoCompactV2;
124
+ /**
125
+ * Compact V3 asymmetric encryption: [0xC3][recipientIdHash(8)][ephemeralPK(32)][nonce(24)][ciphertext+MAC]
126
+ * Saves 8 bytes vs standard V3 format by using 1-byte tag instead of 8-byte version + 1-byte schema
127
+ */
128
+ private encryptUtxoCompactV3;
114
129
  encryptUtxoDecryptedDoNotUse(utxo: Utxo): Buffer;
115
130
  getEncryptionKeyVersion(encryptedData: Buffer | string): 'v1' | 'v2' | 'v3';
116
131
  /**
@@ -122,6 +137,14 @@ export declare class EncryptionService {
122
137
  * @returns null if schema version matches or is legacy, otherwise the mismatched version byte
123
138
  */
124
139
  private checkSchemaVersionMismatch;
140
+ /**
141
+ * Decrypt compact V2 format: [0xC2][recipientIdHash(8)][IV(12)][authTag(16)][ciphertext]
142
+ */
143
+ private decryptCompactV2;
144
+ /**
145
+ * Decrypt compact V3 format: [0xC3][recipientIdHash(8)][ephemeralPK(32)][nonce(24)][ciphertext+MAC]
146
+ */
147
+ private decryptCompactV3;
125
148
  /**
126
149
  * Decrypt an encrypted UTXO and parse it to a Utxo instance
127
150
  * Automatically detects the UTXO version based on the encryption format
@@ -1,3 +1,4 @@
1
+ import { PublicKey } from '@solana/web3.js';
1
2
  import nacl from 'tweetnacl';
2
3
  import { sha256 } from '@noble/hashes/sha256';
3
4
  import { hmac } from '@noble/hashes/hmac';
@@ -20,6 +21,10 @@ export class EncryptionService {
20
21
  // Version 0x01: Original format without recipient ID
21
22
  // Version 0x02: New format with recipient ID hash for O(1) early termination
22
23
  static SIGNATURE_SCHEMA_VERSION = Buffer.from([0x02]); // Schema version 2
24
+ // Compact encryption tags — single byte replaces 8-byte version + 1-byte schema
25
+ // Uses high bytes (0xC2/0xC3) to avoid collision with legacy V1 random IV bytes
26
+ static COMPACT_V2_TAG = 0xC2; // Compact symmetric (AES-256-GCM)
27
+ static COMPACT_V3_TAG = 0xC3; // Compact asymmetric (nacl.box)
23
28
  // Length of recipient ID hash in bytes (first 8 bytes of SHA256(X25519 public key))
24
29
  static RECIPIENT_ID_LENGTH = 8;
25
30
  encryptionKeyV1 = null;
@@ -114,7 +119,14 @@ export class EncryptionService {
114
119
  * @returns true if we should attempt decryption, false to skip
115
120
  */
116
121
  shouldAttemptDecryption(encryptedBuffer) {
117
- // Minimum length for new format: version(8) + schema(1) + recipientId(8) = 17 bytes
122
+ // Compact format: [tag(1)][recipientIdHash(8)]...
123
+ if (encryptedBuffer.length >= 9 &&
124
+ (encryptedBuffer[0] === EncryptionService.COMPACT_V2_TAG || encryptedBuffer[0] === EncryptionService.COMPACT_V3_TAG)) {
125
+ const storedRecipientId = encryptedBuffer.slice(1, 1 + EncryptionService.RECIPIENT_ID_LENGTH);
126
+ const ourRecipientId = this.deriveRecipientIdHash();
127
+ return storedRecipientId.equals(ourRecipientId);
128
+ }
129
+ // Minimum length for legacy format: version(8) + schema(1) + recipientId(8) = 17 bytes
118
130
  if (encryptedBuffer.length < 17) {
119
131
  // Too short for new format, let normal decryption handle it
120
132
  return true;
@@ -268,9 +280,13 @@ export class EncryptionService {
268
280
  /**
269
281
  * Encrypt data using Asymmetric Encryption (X25519 + XSalsa20-Poly1305)
270
282
  * @param data The data to encrypt
271
- * @param recipientPublicKey The recipient's X25519 Public Key
283
+ * @param recipientPublicKey The recipient's X25519 Public Key (must be exactly 32 bytes)
272
284
  */
273
285
  encryptAsymmetric(data, recipientPublicKey) {
286
+ // Validate X25519 public key length (must be exactly 32 bytes)
287
+ if (!recipientPublicKey || recipientPublicKey.length !== 32) {
288
+ throw new Error(`Invalid recipientPublicKey: X25519 public keys must be exactly 32 bytes, got ${recipientPublicKey?.length ?? 0}`);
289
+ }
274
290
  const dataBuffer = typeof data === 'string' ? Buffer.from(data) : data;
275
291
  // Log the recipient's X25519 public key being used for encryption (sender side)
276
292
  debugLogger.x25519EncryptionKey(hashForLog(recipientPublicKey));
@@ -436,25 +452,83 @@ export class EncryptionService {
436
452
  this.walletAddress = null;
437
453
  }
438
454
  /**
439
- * Encrypt a UTXO using a compact pipe-delimited format
440
- * Always uses V2 encryption format. The UTXO's version property is used only for key derivation.
455
+ * Encrypt a UTXO using compact binary encoding and compact encryption headers.
456
+ * Plaintext: 77-byte binary [0x01][amount:8][blinding:32][index:4][mint:32]
457
+ * Encryption: compact V2 (0xC2 tag, AES-GCM) or compact V3 (0xC3 tag, NaCl Box)
441
458
  * @param utxo The UTXO to encrypt (includes version property)
442
459
  * @param recipientEncryptionKey Optional recipient X25519 public key for asymmetric encryption
443
460
  * @returns The encrypted UTXO data as a Buffer
444
461
  * @throws Error if the V2 encryption key has not been set
445
462
  */
463
+ // Binary format flag for compact UTXO encoding
464
+ static BINARY_UTXO_FLAG = 0x01;
465
+ static BINARY_UTXO_LENGTH = 77; // 1 + 8 + 32 + 4 + 32
446
466
  encryptUtxo(utxo, recipientEncryptionKey) {
447
467
  if (!this.encryptionKeyV2) {
448
468
  throw new Error('Encryption key not set. Call setEncryptionKey or deriveEncryptionKeyFromWallet first.');
449
469
  }
450
- // Create a compact string representation using pipe delimiter
451
- // Version is stored in the UTXO model, not in the encrypted content
452
- const utxoString = `${utxo.amount.toString()}|${utxo.blinding.toString()}|${utxo.index}|${utxo.mintAddress}`;
470
+ // Compact binary encoding: [0x01][amount:8 BE][blinding:32 BE][index:4 BE][mintAddress:32 raw]
471
+ // 77 bytes total saves ~55 bytes vs pipe-delimited text, keeping SPL deposits in one tx
472
+ const buf = Buffer.alloc(EncryptionService.BINARY_UTXO_LENGTH);
473
+ buf[0] = EncryptionService.BINARY_UTXO_FLAG;
474
+ // Convert via toString() to support both BN instances and plain objects
475
+ const amountBN = new BN(utxo.amount.toString());
476
+ const blindingBN = new BN(utxo.blinding.toString());
477
+ // Amount: 8 bytes big-endian
478
+ Buffer.from(amountBN.toArray('be', 8)).copy(buf, 1);
479
+ // Blinding: 32 bytes big-endian
480
+ Buffer.from(blindingBN.toArray('be', 32)).copy(buf, 9);
481
+ // Index: 4 bytes big-endian (uint32)
482
+ buf.writeUInt32BE(utxo.index, 41);
483
+ // Mint address: 32 bytes raw (base58-decoded Solana public key)
484
+ const mintAddr = utxo.mintAddress || '11111111111111111111111111111112';
485
+ Buffer.from(new PublicKey(mintAddr).toBytes()).copy(buf, 45);
486
+ // Use compact encryption headers (1-byte tag instead of 9-byte version+schema)
487
+ // Saves 8 bytes per output (16 total) to keep SPL paylink deposits in one tx
453
488
  if (recipientEncryptionKey) {
454
- return this.encryptAsymmetric(utxoString, recipientEncryptionKey);
489
+ return this.encryptUtxoCompactV3(buf, recipientEncryptionKey);
490
+ }
491
+ return this.encryptUtxoCompactV2(buf);
492
+ }
493
+ /**
494
+ * Compact V2 symmetric encryption: [0xC2][recipientIdHash(8)][IV(12)][authTag(16)][ciphertext]
495
+ * Saves 8 bytes vs standard V2 format by using 1-byte tag instead of 8-byte version + 1-byte schema
496
+ */
497
+ encryptUtxoCompactV2(data) {
498
+ const key = Buffer.from(this.encryptionKeyV2);
499
+ const iv = nacl.randomBytes(12);
500
+ const stream = gcm(key, iv);
501
+ const encryptedWithTag = stream.encrypt(data);
502
+ const authTag = Buffer.from(encryptedWithTag.slice(-16));
503
+ const ciphertext = Buffer.from(encryptedWithTag.slice(0, -16));
504
+ const recipientIdHash = this.deriveRecipientIdHash();
505
+ return Buffer.concat([
506
+ Buffer.from([EncryptionService.COMPACT_V2_TAG]),
507
+ recipientIdHash,
508
+ iv,
509
+ authTag,
510
+ ciphertext
511
+ ]);
512
+ }
513
+ /**
514
+ * Compact V3 asymmetric encryption: [0xC3][recipientIdHash(8)][ephemeralPK(32)][nonce(24)][ciphertext+MAC]
515
+ * Saves 8 bytes vs standard V3 format by using 1-byte tag instead of 8-byte version + 1-byte schema
516
+ */
517
+ encryptUtxoCompactV3(data, recipientPublicKey) {
518
+ if (!recipientPublicKey || recipientPublicKey.length !== 32) {
519
+ throw new Error(`Invalid recipientPublicKey: X25519 keys must be exactly 32 bytes, got ${recipientPublicKey?.length ?? 0}`);
455
520
  }
456
- // Always use V2 encryption format (which adds version byte 0x02 at the beginning)
457
- return this.encrypt(utxoString);
521
+ const ephemeralKeypair = nacl.box.keyPair();
522
+ const nonce = nacl.randomBytes(nacl.box.nonceLength);
523
+ const encrypted = nacl.box(data, nonce, recipientPublicKey, ephemeralKeypair.secretKey);
524
+ const recipientIdHash = this.deriveRecipientIdHash(recipientPublicKey);
525
+ return Buffer.concat([
526
+ Buffer.from([EncryptionService.COMPACT_V3_TAG]),
527
+ recipientIdHash,
528
+ ephemeralKeypair.publicKey,
529
+ nonce,
530
+ encrypted
531
+ ]);
458
532
  }
459
533
  // Deprecated, only used for testing now
460
534
  encryptUtxoDecryptedDoNotUse(utxo) {
@@ -467,6 +541,17 @@ export class EncryptionService {
467
541
  getEncryptionKeyVersion(encryptedData) {
468
542
  const buffer = typeof encryptedData === 'string' ? Buffer.from(encryptedData, 'hex') : encryptedData;
469
543
  const dataHash = hashForLog(buffer);
544
+ // Check compact format tags first (single byte at position 0)
545
+ if (buffer.length >= 1) {
546
+ if (buffer[0] === EncryptionService.COMPACT_V2_TAG) {
547
+ debugLogger.versionDetected(dataHash, 'v2', buffer.length);
548
+ return 'v2';
549
+ }
550
+ if (buffer[0] === EncryptionService.COMPACT_V3_TAG) {
551
+ debugLogger.versionDetected(dataHash, 'v3', buffer.length);
552
+ return 'v3';
553
+ }
554
+ }
470
555
  // Log the first 8 bytes (version prefix) for debugging
471
556
  const prefixBytes = buffer.length >= 8 ? buffer.subarray(0, 8) : buffer;
472
557
  const prefixHex = Buffer.from(prefixBytes).toString('hex');
@@ -503,6 +588,11 @@ export class EncryptionService {
503
588
  if (encryptionVersion === 'v1') {
504
589
  return null;
505
590
  }
591
+ // Compact format has no separate schema version byte — tag encodes everything
592
+ if (encryptedBuffer.length >= 1 &&
593
+ (encryptedBuffer[0] === EncryptionService.COMPACT_V2_TAG || encryptedBuffer[0] === EncryptionService.COMPACT_V3_TAG)) {
594
+ return null;
595
+ }
506
596
  // Check if we have enough bytes to contain schema version
507
597
  if (encryptedBuffer.length < 9) {
508
598
  return null; // Too short, let decryption handle the error
@@ -531,6 +621,42 @@ export class EncryptionService {
531
621
  }
532
622
  return null; // Assume legacy format or compatible version, attempt decryption
533
623
  }
624
+ /**
625
+ * Decrypt compact V2 format: [0xC2][recipientIdHash(8)][IV(12)][authTag(16)][ciphertext]
626
+ */
627
+ decryptCompactV2(encryptedBuffer) {
628
+ if (!this.encryptionKeyV2) {
629
+ throw new Error('encryptionKeyV2 not set.');
630
+ }
631
+ const iv = encryptedBuffer.slice(9, 21);
632
+ const authTag = encryptedBuffer.slice(21, 37);
633
+ const ciphertext = encryptedBuffer.slice(37);
634
+ const key = Buffer.from(this.encryptionKeyV2);
635
+ const ciphertextWithTag = Buffer.concat([ciphertext, authTag]);
636
+ try {
637
+ const stream = gcm(key, iv);
638
+ return Buffer.from(stream.decrypt(ciphertextWithTag));
639
+ }
640
+ catch (error) {
641
+ throw new Error('Failed to decrypt data. Invalid encryption key or corrupted data.');
642
+ }
643
+ }
644
+ /**
645
+ * Decrypt compact V3 format: [0xC3][recipientIdHash(8)][ephemeralPK(32)][nonce(24)][ciphertext+MAC]
646
+ */
647
+ decryptCompactV3(encryptedBuffer) {
648
+ if (!this.asymmetricSecretKey) {
649
+ throw new Error('Asymmetric secret key not set.');
650
+ }
651
+ const ephemeralPublicKey = encryptedBuffer.slice(9, 41);
652
+ const nonce = encryptedBuffer.slice(41, 65);
653
+ const box = encryptedBuffer.slice(65);
654
+ const decrypted = nacl.box.open(box, nonce, ephemeralPublicKey, this.asymmetricSecretKey);
655
+ if (!decrypted) {
656
+ throw new Error('Failed to decrypt compact V3 data');
657
+ }
658
+ return Buffer.from(decrypted);
659
+ }
534
660
  /**
535
661
  * Decrypt an encrypted UTXO and parse it to a Utxo instance
536
662
  * Automatically detects the UTXO version based on the encryption format
@@ -570,7 +696,18 @@ export class EncryptionService {
570
696
  return null;
571
697
  }
572
698
  let decrypted;
573
- if (utxoVersion === 'v3') {
699
+ // Compact format: single-byte tag at position 0
700
+ const isCompactV2 = encryptedBuffer[0] === EncryptionService.COMPACT_V2_TAG;
701
+ const isCompactV3 = encryptedBuffer[0] === EncryptionService.COMPACT_V3_TAG;
702
+ if (isCompactV2) {
703
+ decrypted = this.decryptCompactV2(encryptedBuffer);
704
+ utxoVersion = 'v2';
705
+ }
706
+ else if (isCompactV3) {
707
+ decrypted = this.decryptCompactV3(encryptedBuffer);
708
+ utxoVersion = 'v2'; // V3 uses V2 private keys for UTXO logic
709
+ }
710
+ else if (utxoVersion === 'v3') {
574
711
  decrypted = this.decryptV3(encryptedBuffer);
575
712
  // V3 also uses V2 private keys for the UTXO logic
576
713
  utxoVersion = 'v2';
@@ -584,24 +721,39 @@ export class EncryptionService {
584
721
  debugLogger.decryptionFailure(originalVersion, 'LEGACY_FORMAT_SKIPPED', 'Old format UTXO without schema version byte - returning null');
585
722
  return null;
586
723
  }
587
- // Parse the pipe-delimited format: amount|blinding|index|mintAddress
588
- const decryptedStr = decrypted.toString();
589
- const parts = decryptedStr.split('|');
590
- if (parts.length !== 4) {
591
- debugLogger.decryptionFailure(originalVersion, 'INVALID_UTXO_FORMAT', `Expected 4 pipe-delimited parts, got ${parts.length}`, {
592
- partsCount: parts.length
593
- });
594
- throw new Error('Invalid UTXO format after decryption');
595
- }
596
- const [amount, blinding, index, mintAddress] = parts;
597
- if (!amount || !blinding || index === undefined || mintAddress === undefined) {
598
- debugLogger.decryptionFailure(originalVersion, 'MISSING_UTXO_FIELDS', 'One or more required UTXO fields are missing', {
599
- hasAmount: !!amount,
600
- hasBlinding: !!blinding,
601
- hasIndex: index !== undefined,
602
- hasMintAddress: mintAddress !== undefined
603
- });
604
- throw new Error('Invalid UTXO format after decryption');
724
+ // Detect format: 0x01 + 77 bytes = compact binary, otherwise legacy pipe-delimited text
725
+ let amount, blinding, parsedIndex, mintAddress;
726
+ if (decrypted[0] === EncryptionService.BINARY_UTXO_FLAG && decrypted.length === EncryptionService.BINARY_UTXO_LENGTH) {
727
+ // Compact binary: [0x01][amount:8 BE][blinding:32 BE][index:4 BE][mintAddress:32 raw]
728
+ amount = new BN(decrypted.subarray(1, 9), 'be').toString();
729
+ blinding = new BN(decrypted.subarray(9, 41), 'be').toString();
730
+ parsedIndex = decrypted.readUInt32BE(41);
731
+ mintAddress = new PublicKey(decrypted.subarray(45, 77)).toBase58();
732
+ }
733
+ else {
734
+ // Legacy pipe-delimited text: amount|blinding|index|mintAddress
735
+ const decryptedStr = decrypted.toString();
736
+ const parts = decryptedStr.split('|');
737
+ if (parts.length !== 4) {
738
+ debugLogger.decryptionFailure(originalVersion, 'INVALID_UTXO_FORMAT', `Expected 4 pipe-delimited parts, got ${parts.length}`, {
739
+ partsCount: parts.length
740
+ });
741
+ throw new Error('Invalid UTXO format after decryption');
742
+ }
743
+ const [a, b, idx, mint] = parts;
744
+ if (!a || !b || idx === undefined || mint === undefined) {
745
+ debugLogger.decryptionFailure(originalVersion, 'MISSING_UTXO_FIELDS', 'One or more required UTXO fields are missing', {
746
+ hasAmount: !!a,
747
+ hasBlinding: !!b,
748
+ hasIndex: idx !== undefined,
749
+ hasMintAddress: mint !== undefined
750
+ });
751
+ throw new Error('Invalid UTXO format after decryption');
752
+ }
753
+ amount = a;
754
+ blinding = b;
755
+ parsedIndex = Number(idx);
756
+ mintAddress = mint;
605
757
  }
606
758
  // Get or create a LightWasm instance
607
759
  const wasmInstance = lightWasm || await WasmFactory.getInstance();
@@ -612,13 +764,13 @@ export class EncryptionService {
612
764
  amount: amount,
613
765
  blinding: blinding,
614
766
  keypair: new UtxoKeypair(privateKey, wasmInstance),
615
- index: Number(index),
767
+ index: parsedIndex,
616
768
  mintAddress: mintAddress,
617
769
  version: utxoVersion
618
770
  });
619
771
  // Log UTXO metadata after successful decryption
620
772
  const commitment = await utxo.getCommitment();
621
- debugLogger.utxoDecrypted(hashForLog(Buffer.from(commitment)), mintAddress, encryptedBuffer.length, index, originalVersion);
773
+ debugLogger.utxoDecrypted(hashForLog(Buffer.from(commitment)), mintAddress, encryptedBuffer.length, parsedIndex, originalVersion);
622
774
  return utxo;
623
775
  }
624
776
  getUtxoPrivateKeyWithVersion(version) {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Exponential backoff delay calculation
3
+ *
4
+ * @param attempt Current attempt number (0-based)
5
+ * @param baseDelayMs Base delay in milliseconds (default: 2000ms)
6
+ * @param maxDelayMs Maximum delay cap in milliseconds (default: 30000ms)
7
+ * @param jitterFactor Random jitter factor 0-1 (default: 0.1 = 10%)
8
+ * @returns Delay in milliseconds
9
+ */
10
+ export declare function getExponentialBackoffDelay(attempt: number, baseDelayMs?: number, maxDelayMs?: number, jitterFactor?: number): number;
11
+ /**
12
+ * Sleep for a specified duration with exponential backoff
13
+ *
14
+ * @param attempt Current attempt number (0-based)
15
+ * @param options Optional configuration
16
+ */
17
+ export declare function sleepWithBackoff(attempt: number, options?: {
18
+ baseDelayMs?: number;
19
+ maxDelayMs?: number;
20
+ jitterFactor?: number;
21
+ }): Promise<number>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Exponential backoff delay calculation
3
+ *
4
+ * @param attempt Current attempt number (0-based)
5
+ * @param baseDelayMs Base delay in milliseconds (default: 2000ms)
6
+ * @param maxDelayMs Maximum delay cap in milliseconds (default: 30000ms)
7
+ * @param jitterFactor Random jitter factor 0-1 (default: 0.1 = 10%)
8
+ * @returns Delay in milliseconds
9
+ */
10
+ export function getExponentialBackoffDelay(attempt, baseDelayMs = 2000, maxDelayMs = 30000, jitterFactor = 0.1) {
11
+ // Exponential: base * 2^attempt (e.g., 2s, 4s, 8s, 16s, 30s max)
12
+ const exponentialDelay = baseDelayMs * Math.pow(2, attempt);
13
+ // Cap at maximum delay
14
+ const cappedDelay = Math.min(exponentialDelay, maxDelayMs);
15
+ // Add jitter to prevent thundering herd
16
+ const jitter = cappedDelay * jitterFactor * Math.random();
17
+ const finalDelay = cappedDelay + jitter;
18
+ return Math.floor(finalDelay);
19
+ }
20
+ /**
21
+ * Sleep for a specified duration with exponential backoff
22
+ *
23
+ * @param attempt Current attempt number (0-based)
24
+ * @param options Optional configuration
25
+ */
26
+ export async function sleepWithBackoff(attempt, options) {
27
+ const delayMs = getExponentialBackoffDelay(attempt, options?.baseDelayMs, options?.maxDelayMs, options?.jitterFactor);
28
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
29
+ return delayMs;
30
+ }
package/dist/withdraw.js CHANGED
@@ -10,6 +10,7 @@ import { serializeProofAndExtData, } from "./utils/encryption.js";
10
10
  import { fetchMerkleProof, findNullifierPDAs, getExtDataHash, getProgramAccounts, queryRemoteTreeState, findCrossCheckNullifierPDAs, } from "./utils/utils.js";
11
11
  import { getUtxos } from "./getUtxos.js";
12
12
  import { logger } from "./utils/logger.js";
13
+ import { sleepWithBackoff } from "./utils/retry.js";
13
14
  import { getConfig } from "./config.js";
14
15
  // Indexer API endpoint
15
16
  // Function to submit withdraw request to indexer backend
@@ -262,16 +263,21 @@ export async function withdraw({ recipient, lightWasm, storage, publicKey, conne
262
263
  // Wait a moment for the transaction to be confirmed
263
264
  logger.info("waiting for transaction confirmation...");
264
265
  let retryTimes = 0;
265
- let itv = 2;
266
+ const maxRetries = 10;
266
267
  const encryptedOutputStr = Buffer.from(encryptedOutput1).toString("hex");
267
- let start = Date.now();
268
+ const start = Date.now();
268
269
  while (true) {
269
270
  logger.info("Confirming transaction..");
270
271
  logger.debug(`retryTimes: ${retryTimes}`);
271
- await new Promise((resolve) => setTimeout(resolve, itv * 1000));
272
+ // Use exponential backoff: 2s, 4s, 8s, 16s, up to 30s max
273
+ const delayMs = await sleepWithBackoff(retryTimes, {
274
+ baseDelayMs: 2000,
275
+ maxDelayMs: 30000,
276
+ });
277
+ logger.debug(`Waited ${delayMs}ms before retry`);
272
278
  logger.info("Fetching updated tree state...");
273
- let res = await fetch(RELAYER_API_URL + "/utxos/check/" + encryptedOutputStr);
274
- let resJson = await res.json();
279
+ const res = await fetch(RELAYER_API_URL + "/utxos/check/" + encryptedOutputStr);
280
+ const resJson = await res.json();
275
281
  logger.debug("resJson:", resJson);
276
282
  if (resJson.exists) {
277
283
  return {
@@ -282,9 +288,9 @@ export async function withdraw({ recipient, lightWasm, storage, publicKey, conne
282
288
  fee_in_lamports,
283
289
  };
284
290
  }
285
- if (retryTimes >= 10) {
286
- throw new TransactionTimeoutError(`Transaction confirmation timeout after ${retryTimes * 3} seconds`, signature);
287
- }
288
291
  retryTimes++;
292
+ if (retryTimes >= maxRetries) {
293
+ throw new TransactionTimeoutError(`Transaction confirmation timeout after ${((Date.now() - start) / 1000).toFixed(0)} seconds`, signature);
294
+ }
289
295
  }
290
296
  }
@@ -9,6 +9,7 @@ import { serializeProofAndExtData, } from "./utils/encryption.js";
9
9
  import { fetchMerkleProof, findNullifierPDAs, getProgramAccounts, queryRemoteTreeState, findCrossCheckNullifierPDAs, getMintAddressField, getExtDataHash, } from "./utils/utils.js";
10
10
  import { getUtxosSPL } from "./getUtxosSPL.js";
11
11
  import { logger } from "./utils/logger.js";
12
+ import { sleepWithBackoff } from "./utils/retry.js";
12
13
  import { getConfig } from "./config.js";
13
14
  import { getAssociatedTokenAddressSync, getMint } from "@solana/spl-token";
14
15
  // Indexer API endpoint
@@ -297,20 +298,25 @@ export async function withdrawSPL({ recipient, lightWasm, storage, publicKey, co
297
298
  // Wait a moment for the transaction to be confirmed
298
299
  logger.info("waiting for transaction confirmation...");
299
300
  let retryTimes = 0;
300
- let itv = 2;
301
+ const maxRetries = 10;
301
302
  const encryptedOutputStr = Buffer.from(encryptedOutput1).toString("hex");
302
- let start = Date.now();
303
+ const start = Date.now();
303
304
  while (true) {
304
305
  logger.info("Confirming transaction..");
305
306
  logger.debug(`retryTimes: ${retryTimes}`);
306
- await new Promise((resolve) => setTimeout(resolve, itv * 1000));
307
+ // Use exponential backoff: 2s, 4s, 8s, 16s, up to 30s max
308
+ const delayMs = await sleepWithBackoff(retryTimes, {
309
+ baseDelayMs: 2000,
310
+ maxDelayMs: 30000,
311
+ });
312
+ logger.debug(`Waited ${delayMs}ms before retry`);
307
313
  logger.info("Fetching updated tree state...");
308
- let res = await fetch(RELAYER_API_URL +
314
+ const res = await fetch(RELAYER_API_URL +
309
315
  "/utxos/check/" +
310
316
  encryptedOutputStr +
311
317
  "?token=" +
312
318
  token.name);
313
- let resJson = await res.json();
319
+ const resJson = await res.json();
314
320
  logger.debug("resJson:", resJson);
315
321
  if (resJson.exists) {
316
322
  return {
@@ -321,9 +327,9 @@ export async function withdrawSPL({ recipient, lightWasm, storage, publicKey, co
321
327
  fee_base_units,
322
328
  };
323
329
  }
324
- if (retryTimes >= 10) {
330
+ retryTimes++;
331
+ if (retryTimes >= maxRetries) {
325
332
  throw new Error("Refresh the page to see latest balance.");
326
333
  }
327
- retryTimes++;
328
334
  }
329
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velumdotcash/sdk",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "TypeScript SDK for private payments on Solana using Zero-Knowledge proofs",
5
5
  "main": "dist/index.js",
6
6
  "exports": {