@velumdotcash/sdk 2.0.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/dist/__tests__/paylink.test.d.ts +9 -0
  4. package/dist/__tests__/paylink.test.js +254 -0
  5. package/dist/config.d.ts +9 -0
  6. package/dist/config.js +12 -0
  7. package/dist/deposit.d.ts +22 -0
  8. package/dist/deposit.js +445 -0
  9. package/dist/depositSPL.d.ts +24 -0
  10. package/dist/depositSPL.js +499 -0
  11. package/dist/errors.d.ts +78 -0
  12. package/dist/errors.js +127 -0
  13. package/dist/exportUtils.d.ts +10 -0
  14. package/dist/exportUtils.js +10 -0
  15. package/dist/getUtxos.d.ts +30 -0
  16. package/dist/getUtxos.js +335 -0
  17. package/dist/getUtxosSPL.d.ts +34 -0
  18. package/dist/getUtxosSPL.js +442 -0
  19. package/dist/index.d.ts +183 -0
  20. package/dist/index.js +436 -0
  21. package/dist/models/keypair.d.ts +26 -0
  22. package/dist/models/keypair.js +43 -0
  23. package/dist/models/utxo.d.ts +51 -0
  24. package/dist/models/utxo.js +99 -0
  25. package/dist/test_paylink_logic.test.d.ts +1 -0
  26. package/dist/test_paylink_logic.test.js +114 -0
  27. package/dist/utils/address_lookup_table.d.ts +9 -0
  28. package/dist/utils/address_lookup_table.js +45 -0
  29. package/dist/utils/constants.d.ts +27 -0
  30. package/dist/utils/constants.js +56 -0
  31. package/dist/utils/debug-logger.d.ts +250 -0
  32. package/dist/utils/debug-logger.js +688 -0
  33. package/dist/utils/encryption.d.ts +152 -0
  34. package/dist/utils/encryption.js +700 -0
  35. package/dist/utils/logger.d.ts +9 -0
  36. package/dist/utils/logger.js +35 -0
  37. package/dist/utils/merkle_tree.d.ts +92 -0
  38. package/dist/utils/merkle_tree.js +186 -0
  39. package/dist/utils/node-shim.d.ts +14 -0
  40. package/dist/utils/node-shim.js +21 -0
  41. package/dist/utils/prover.d.ts +36 -0
  42. package/dist/utils/prover.js +169 -0
  43. package/dist/utils/utils.d.ts +64 -0
  44. package/dist/utils/utils.js +165 -0
  45. package/dist/withdraw.d.ts +22 -0
  46. package/dist/withdraw.js +290 -0
  47. package/dist/withdrawSPL.d.ts +24 -0
  48. package/dist/withdrawSPL.js +329 -0
  49. package/package.json +59 -0
@@ -0,0 +1,700 @@
1
+ import nacl from 'tweetnacl';
2
+ import { sha256 } from '@noble/hashes/sha256';
3
+ import { hmac } from '@noble/hashes/hmac';
4
+ import { gcm, ctr } from '@noble/ciphers/aes';
5
+ import { Utxo } from '../models/utxo.js';
6
+ import { WasmFactory } from '@lightprotocol/hasher.rs';
7
+ import { Keypair as UtxoKeypair } from '../models/keypair.js';
8
+ import { keccak256 } from '@ethersproject/keccak256';
9
+ import { TRANSACT_IX_DISCRIMINATOR, TRANSACT_SPL_IX_DISCRIMINATOR } from './constants.js';
10
+ import BN from 'bn.js';
11
+ import { debugLogger, hashForLog } from './debug-logger.js';
12
+ /**
13
+ * Service for handling encryption and decryption of UTXO data
14
+ */
15
+ export class EncryptionService {
16
+ // Version identifier for encryption scheme (8-byte version)
17
+ static ENCRYPTION_VERSION_V2 = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02]); // Version 2
18
+ static ENCRYPTION_VERSION_V3 = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03]); // Version 3 (Asymmetric)
19
+ // Signature schema version - identifies the signature message format and encryption format
20
+ // Version 0x01: Original format without recipient ID
21
+ // Version 0x02: New format with recipient ID hash for O(1) early termination
22
+ static SIGNATURE_SCHEMA_VERSION = Buffer.from([0x02]); // Schema version 2
23
+ // Length of recipient ID hash in bytes (first 8 bytes of SHA256(X25519 public key))
24
+ static RECIPIENT_ID_LENGTH = 8;
25
+ encryptionKeyV1 = null;
26
+ encryptionKeyV2 = null;
27
+ asymmetricSecretKey = null; // X25519 Secret Key
28
+ utxoPrivateKeyV1 = null;
29
+ utxoPrivateKeyV2 = null;
30
+ walletAddress = null; // Wallet address for key derivation logging
31
+ /**
32
+ * Generate an encryption key from a signature
33
+ * @param signature The user's signature
34
+ * @param walletAddress Optional wallet address for logging/verification
35
+ * @returns The generated encryption key
36
+ */
37
+ deriveEncryptionKeyFromSignature(signature, walletAddress) {
38
+ // Store wallet address for logging
39
+ if (walletAddress) {
40
+ this.walletAddress = walletAddress;
41
+ debugLogger.walletKeyDerivation(walletAddress, 'derive');
42
+ }
43
+ debugLogger.keyDerivation('START', hashForLog(signature), 'wallet_signature');
44
+ // Extract the first 31 bytes of the signature to create a deterministic key (legacy method)
45
+ const encryptionKeyV1 = signature.slice(0, 31);
46
+ // Store the V1 key in the service
47
+ this.encryptionKeyV1 = encryptionKeyV1;
48
+ debugLogger.keyDerivation('V1_KEY_DERIVED', hashForLog(encryptionKeyV1), 'encryption_key_v1');
49
+ // Precompute and cache the UTXO private key
50
+ const hashedSeedV1 = sha256(encryptionKeyV1);
51
+ this.utxoPrivateKeyV1 = '0x' + Buffer.from(hashedSeedV1).toString('hex');
52
+ debugLogger.keyDerivation('V1_UTXO_PRIVATE_KEY', hashForLog(hashedSeedV1), 'utxo_private_key_v1');
53
+ // Use Keccak256 to derive a full 32-byte encryption key from the signature
54
+ const encryptionKeyV2 = Buffer.from(keccak256(signature).slice(2), 'hex');
55
+ // Store the V2 key in the service
56
+ this.encryptionKeyV2 = encryptionKeyV2;
57
+ debugLogger.keyDerivation('V2_KEY_DERIVED', hashForLog(encryptionKeyV2), 'encryption_key_v2');
58
+ // Derive asymmetric key from V2 key (deterministic)
59
+ // We use the first 32 bytes of a hash of the V2 key as the seed for the X25519 keypair
60
+ const asymmetricSeed = sha256(encryptionKeyV2);
61
+ debugLogger.keyDerivation('ASYMMETRIC_SEED', hashForLog(asymmetricSeed), 'x25519_seed');
62
+ const keypair = nacl.box.keyPair.fromSecretKey(asymmetricSeed);
63
+ this.asymmetricSecretKey = keypair.secretKey;
64
+ debugLogger.asymmetricKeyGenerated(hashForLog(keypair.publicKey), hashForLog(keypair.secretKey));
65
+ // Log X25519 public key derivation with wallet address for sender/recipient verification
66
+ const walletAddr = this.walletAddress || '<unknown>';
67
+ debugLogger.x25519KeyDerived(hashForLog(keypair.publicKey), walletAddr, 'recipient');
68
+ // Precompute and cache the UTXO private key
69
+ const hashedSeedV2 = Buffer.from(keccak256(encryptionKeyV2).slice(2), 'hex');
70
+ this.utxoPrivateKeyV2 = '0x' + hashedSeedV2.toString('hex');
71
+ debugLogger.keyDerivation('V2_UTXO_PRIVATE_KEY', hashForLog(hashedSeedV2), 'utxo_private_key_v2');
72
+ debugLogger.serviceInitialized(!!this.encryptionKeyV1, !!this.encryptionKeyV2, !!this.asymmetricSecretKey);
73
+ return {
74
+ v1: this.encryptionKeyV1,
75
+ v2: this.encryptionKeyV2
76
+ };
77
+ }
78
+ /**
79
+ * Generate an encryption key from a wallet keypair (V2 format)
80
+ * @param keypair The Solana keypair to derive the encryption key from
81
+ * @returns The generated encryption key
82
+ */
83
+ deriveEncryptionKeyFromWallet(keypair) {
84
+ // Sign a constant message with the keypair
85
+ const message = Buffer.from('Privacy Money account sign in');
86
+ const signature = nacl.sign.detached(message, keypair.secretKey);
87
+ // Pass wallet address for logging
88
+ return this.deriveEncryptionKeyFromSignature(signature, keypair.publicKey.toBase58());
89
+ }
90
+ /**
91
+ * Get the Asymmetric Public Key (X25519) derived from the encryption key
92
+ */
93
+ getAsymmetricPublicKey() {
94
+ if (!this.asymmetricSecretKey) {
95
+ throw new Error('Asymmetric key not set. Call setEncryptionKey or deriveEncryptionKeyFromWallet first.');
96
+ }
97
+ return nacl.box.keyPair.fromSecretKey(this.asymmetricSecretKey).publicKey;
98
+ }
99
+ /**
100
+ * Derive recipient ID hash from X25519 public key
101
+ * Used for O(1) early termination during UTXO decryption
102
+ * @param publicKey Optional X25519 public key. If not provided, uses own public key.
103
+ * @returns First 8 bytes of SHA256(publicKey)
104
+ */
105
+ deriveRecipientIdHash(publicKey) {
106
+ const key = publicKey || this.getAsymmetricPublicKey();
107
+ const hash = sha256(key);
108
+ return Buffer.from(hash.slice(0, EncryptionService.RECIPIENT_ID_LENGTH));
109
+ }
110
+ /**
111
+ * Check if encrypted data is for this wallet (O(1) operation)
112
+ * Enables early termination without attempting expensive crypto decryption
113
+ * @param encryptedBuffer The encrypted data buffer
114
+ * @returns true if we should attempt decryption, false to skip
115
+ */
116
+ shouldAttemptDecryption(encryptedBuffer) {
117
+ // Minimum length for new format: version(8) + schema(1) + recipientId(8) = 17 bytes
118
+ if (encryptedBuffer.length < 17) {
119
+ // Too short for new format, let normal decryption handle it
120
+ return true;
121
+ }
122
+ const schemaVersion = encryptedBuffer[8];
123
+ // Old format (schema version 0x01 or less) - attempt decryption for backward compat
124
+ if (schemaVersion < 0x02) {
125
+ return true;
126
+ }
127
+ // Unknown future schema version (> 0x02) - reject
128
+ if (schemaVersion > 0x02) {
129
+ return false;
130
+ }
131
+ // Current format (schema version 0x02) - check recipient ID hash
132
+ const storedRecipientId = encryptedBuffer.slice(9, 9 + EncryptionService.RECIPIENT_ID_LENGTH);
133
+ const ourRecipientId = this.deriveRecipientIdHash();
134
+ return storedRecipientId.equals(ourRecipientId);
135
+ }
136
+ /**
137
+ * Encrypt data with the stored encryption key
138
+ * @param data The data to encrypt
139
+ * @returns The encrypted data as a Buffer
140
+ * @throws Error if the encryption key has not been generated
141
+ */
142
+ encrypt(data) {
143
+ if (!this.encryptionKeyV2) {
144
+ throw new Error('Encryption key not set. Call setEncryptionKey or deriveEncryptionKeyFromWallet first.');
145
+ }
146
+ // Convert string to Buffer if needed
147
+ const dataBuffer = typeof data === 'string' ? Buffer.from(data) : data;
148
+ // Generate a standard initialization vector (12 bytes for GCM)
149
+ const iv = nacl.randomBytes(12);
150
+ // Use the full 32-byte V2 encryption key for AES-256
151
+ const key = Buffer.from(this.encryptionKeyV2);
152
+ // Use AES-256-GCM for authenticated encryption
153
+ const stream = gcm(key, iv);
154
+ const encryptedWithTag = stream.encrypt(dataBuffer);
155
+ // Noble returns [ciphertext | authTag] (tag is last 16 bytes)
156
+ const authTag = Buffer.from(encryptedWithTag.slice(-16));
157
+ const encryptedData = Buffer.from(encryptedWithTag.slice(0, -16));
158
+ // Derive recipient ID hash for O(1) early termination during decryption
159
+ const recipientIdHash = this.deriveRecipientIdHash();
160
+ // Version 2 format (schema 0x02): [version(8)] + [schemaVersion(1)] + [recipientIdHash(8)] + [IV(12)] + [authTag(16)] + [encryptedData]
161
+ return Buffer.concat([
162
+ EncryptionService.ENCRYPTION_VERSION_V2,
163
+ EncryptionService.SIGNATURE_SCHEMA_VERSION,
164
+ recipientIdHash,
165
+ iv,
166
+ authTag,
167
+ encryptedData
168
+ ]);
169
+ }
170
+ // v1 encryption, only used for testing now
171
+ encryptDecryptedDoNotUse(data) {
172
+ if (!this.encryptionKeyV1) {
173
+ throw new Error('Encryption key not set. Call setEncryptionKey or deriveEncryptionKeyFromWallet first.');
174
+ }
175
+ // Convert string to Buffer if needed
176
+ const dataBuffer = typeof data === 'string' ? Buffer.from(data) : data;
177
+ // Generate a standard initialization vector (16 bytes)
178
+ const iv = nacl.randomBytes(16);
179
+ // Create a key from our encryption key (using only first 16 bytes for AES-128)
180
+ const key = Buffer.from(this.encryptionKeyV1).slice(0, 16);
181
+ // Use a more compact encryption algorithm (aes-128-ctr)
182
+ const stream = ctr(key, iv);
183
+ const encryptedData = Buffer.from(stream.encrypt(dataBuffer));
184
+ // Create an authentication tag (HMAC) to verify decryption with correct key
185
+ const hmacKey = Buffer.from(this.encryptionKeyV1).slice(16, 31);
186
+ const authTag = Buffer.from(hmac(sha256, hmacKey, Buffer.concat([iv, encryptedData]))).slice(0, 16);
187
+ // Combine IV, auth tag and encrypted data
188
+ return Buffer.concat([iv, authTag, encryptedData]);
189
+ }
190
+ /**
191
+ * Decrypt data with the stored encryption key
192
+ * @param encryptedData The encrypted data to decrypt
193
+ * @returns The decrypted data as a Buffer, or null if legacy format (no schema version byte)
194
+ * @throws Error if the encryption key has not been generated or if the wrong key is used
195
+ */
196
+ decrypt(encryptedData) {
197
+ // Check if this is the new version format (starts with 8-byte version identifier)
198
+ if (encryptedData.length >= 8 && encryptedData.subarray(0, 8).equals(EncryptionService.ENCRYPTION_VERSION_V2)) {
199
+ if (!this.encryptionKeyV2) {
200
+ throw new Error('Encryption key not set. Call setEncryptionKey or deriveEncryptionKeyFromWallet first.');
201
+ }
202
+ return this.decryptV2(encryptedData);
203
+ }
204
+ else {
205
+ // V1 format - need V1 key or keypair to derive it
206
+ if (!this.encryptionKeyV1) {
207
+ throw new Error('Encryption key not set. Call setEncryptionKey or deriveEncryptionKeyFromWallet first.');
208
+ }
209
+ return this.decryptV1(encryptedData);
210
+ }
211
+ }
212
+ /**
213
+ * Decrypt data using the old V1 format (120-bit HMAC with SHA256)
214
+ * @param encryptedData The encrypted data to decrypt
215
+ * @param keypair Optional keypair to derive V1 key for backward compatibility
216
+ * @returns The decrypted data as a Buffer
217
+ */
218
+ decryptV1(encryptedData) {
219
+ debugLogger.decryptionAttemptStart('V1', hashForLog(encryptedData), encryptedData.length);
220
+ if (!this.encryptionKeyV1) {
221
+ debugLogger.missingKey('encryptionKeyV1', 'V1 decryption');
222
+ throw new Error('Encryption key not set. Call setEncryptionKey or deriveEncryptionKeyFromWallet first.');
223
+ }
224
+ // Extract the IV from the first 16 bytes
225
+ const iv = encryptedData.slice(0, 16);
226
+ // Extract the auth tag from the next 16 bytes
227
+ const authTag = encryptedData.slice(16, 32);
228
+ // The rest is the actual encrypted data
229
+ const data = encryptedData.slice(32);
230
+ // Verify the authentication tag
231
+ const hmacKey = Buffer.from(this.encryptionKeyV1).slice(16, 31);
232
+ const calculatedTag = Buffer.from(hmac(sha256, hmacKey, Buffer.concat([iv, data]))).slice(0, 16);
233
+ // Compare tags - if they don't match, the key is wrong
234
+ if (!this.timingSafeEqual(authTag, calculatedTag)) {
235
+ debugLogger.decryptionFailure('V1', 'HMAC_MISMATCH', 'Authentication tag verification failed', {
236
+ ivHash: hashForLog(iv),
237
+ authTagHash: hashForLog(authTag),
238
+ calculatedTagHash: hashForLog(calculatedTag)
239
+ });
240
+ throw new Error('Failed to decrypt data. Invalid encryption key or corrupted data.');
241
+ }
242
+ // Create a key from our encryption key (using only first 16 bytes for AES-128)
243
+ const key = Buffer.from(this.encryptionKeyV1).slice(0, 16);
244
+ // Use the same algorithm as in encrypt
245
+ try {
246
+ const stream = ctr(key, iv);
247
+ const decrypted = Buffer.from(stream.decrypt(data));
248
+ debugLogger.decryptionSuccess('V1', decrypted.length);
249
+ return decrypted;
250
+ }
251
+ catch (error) {
252
+ const errMsg = error instanceof Error ? error.message : 'Unknown error';
253
+ debugLogger.decryptionFailure('V1', 'AES_CTR_FAILED', errMsg);
254
+ throw new Error('Failed to decrypt data. Invalid encryption key or corrupted data.');
255
+ }
256
+ }
257
+ // Custom timingSafeEqual for browser compatibility
258
+ timingSafeEqual(a, b) {
259
+ if (a.length !== b.length) {
260
+ return false;
261
+ }
262
+ let diff = 0;
263
+ for (let i = 0; i < a.length; i++) {
264
+ diff |= a[i] ^ b[i];
265
+ }
266
+ return diff === 0;
267
+ }
268
+ /**
269
+ * Encrypt data using Asymmetric Encryption (X25519 + XSalsa20-Poly1305)
270
+ * @param data The data to encrypt
271
+ * @param recipientPublicKey The recipient's X25519 Public Key
272
+ */
273
+ encryptAsymmetric(data, recipientPublicKey) {
274
+ const dataBuffer = typeof data === 'string' ? Buffer.from(data) : data;
275
+ // Log the recipient's X25519 public key being used for encryption (sender side)
276
+ debugLogger.x25519EncryptionKey(hashForLog(recipientPublicKey));
277
+ // Generate ephemeral keypair
278
+ const ephemeralKeypair = nacl.box.keyPair();
279
+ // Generate nonce
280
+ const nonce = nacl.randomBytes(nacl.box.nonceLength);
281
+ // Encrypt
282
+ const encrypted = nacl.box(dataBuffer, nonce, recipientPublicKey, ephemeralKeypair.secretKey);
283
+ // Derive recipient ID hash from recipient's public key for O(1) early termination
284
+ const recipientIdHash = this.deriveRecipientIdHash(recipientPublicKey);
285
+ // Format (schema 0x02): [version(8)] + [schemaVersion(1)] + [recipientIdHash(8)] + [ephemeralPublicKey(32)] + [nonce(24)] + [encryptedData]
286
+ return Buffer.concat([
287
+ EncryptionService.ENCRYPTION_VERSION_V3,
288
+ EncryptionService.SIGNATURE_SCHEMA_VERSION,
289
+ recipientIdHash,
290
+ ephemeralKeypair.publicKey,
291
+ nonce,
292
+ encrypted
293
+ ]);
294
+ }
295
+ /**
296
+ * Decrypt data using Asymmetric Encryption
297
+ * @returns Buffer on success, null if legacy format (no schema version byte) - these are skipped
298
+ */
299
+ decryptV3(encryptedData) {
300
+ debugLogger.decryptionAttemptStart('V3', hashForLog(encryptedData), encryptedData.length);
301
+ if (!this.asymmetricSecretKey) {
302
+ debugLogger.missingKey('asymmetricSecretKey', 'V3 decryption');
303
+ throw new Error('Asymmetric secret key not set.');
304
+ }
305
+ // Log the derived X25519 public key (recipient side) for verification
306
+ const derivedPublicKey = nacl.box.keyPair.fromSecretKey(this.asymmetricSecretKey).publicKey;
307
+ debugLogger.x25519DecryptionKey(hashForLog(derivedPublicKey));
308
+ // Check schema version byte at position 8
309
+ const schemaVersion = encryptedData.length >= 9 ? encryptedData[8] : 0;
310
+ // Skip very old format UTXOs (no schema version)
311
+ if (schemaVersion < 0x01) {
312
+ debugLogger.decryptionFailure('V3', 'LEGACY_FORMAT_SKIPPED', 'Very old format UTXO without schema version byte - skipping', {
313
+ dataLength: encryptedData.length,
314
+ byte8Value: schemaVersion
315
+ });
316
+ return null;
317
+ }
318
+ // Determine byte offsets based on schema version
319
+ // Schema 0x01: [version(8)] + [schema(1)] + [ephemeralPubKey(32)] + [nonce(24)] + [encrypted]
320
+ // Schema 0x02: [version(8)] + [schema(1)] + [recipientIdHash(8)] + [ephemeralPubKey(32)] + [nonce(24)] + [encrypted]
321
+ let ephemeralKeyStart, ephemeralKeyEnd, nonceStart, nonceEnd, boxStart;
322
+ if (schemaVersion >= 0x02) {
323
+ // New format with recipient ID hash (8 bytes)
324
+ ephemeralKeyStart = 9 + EncryptionService.RECIPIENT_ID_LENGTH; // 17
325
+ ephemeralKeyEnd = ephemeralKeyStart + 32; // 49
326
+ nonceStart = ephemeralKeyEnd; // 49
327
+ nonceEnd = nonceStart + 24; // 73
328
+ boxStart = nonceEnd; // 73
329
+ }
330
+ else {
331
+ // Old format (schema 0x01) without recipient ID hash
332
+ ephemeralKeyStart = 9;
333
+ ephemeralKeyEnd = 41;
334
+ nonceStart = 41;
335
+ nonceEnd = 65;
336
+ boxStart = 65;
337
+ }
338
+ const ephemeralPublicKey = encryptedData.slice(ephemeralKeyStart, ephemeralKeyEnd);
339
+ const nonce = encryptedData.slice(nonceStart, nonceEnd);
340
+ const box = encryptedData.slice(boxStart);
341
+ debugLogger.v3DecryptionDetails(hashForLog(ephemeralPublicKey), hashForLog(nonce), box.length, hashForLog(this.asymmetricSecretKey));
342
+ const decrypted = nacl.box.open(box, nonce, ephemeralPublicKey, this.asymmetricSecretKey);
343
+ if (!decrypted) {
344
+ // Log key mismatch details for debugging
345
+ const derivedPubKeyHash = hashForLog(derivedPublicKey);
346
+ debugLogger.x25519KeyMismatch('<encrypted_for_different_key>', derivedPubKeyHash, this.walletAddress || undefined);
347
+ debugLogger.decryptionFailure('V3', 'NACL_BOX_OPEN_FAILED', 'nacl.box.open returned null - key mismatch or corrupted data', {
348
+ ephemeralPubKeyHash: hashForLog(ephemeralPublicKey),
349
+ nonceHash: hashForLog(nonce),
350
+ boxLength: box.length,
351
+ secretKeyHash: hashForLog(this.asymmetricSecretKey),
352
+ derivedPublicKeyHash: derivedPubKeyHash,
353
+ walletAddress: this.walletAddress || '<unknown>',
354
+ schemaVersion
355
+ });
356
+ throw new Error('Failed to decrypt asymmetric data');
357
+ }
358
+ debugLogger.decryptionSuccess('V3', decrypted.length);
359
+ return Buffer.from(decrypted);
360
+ }
361
+ /**
362
+ * Decrypt data using the new V2 format (256-bit Keccak HMAC)
363
+ * @param encryptedData The encrypted data to decrypt
364
+ * @returns The decrypted data as a Buffer, or null if legacy format (no schema version byte) - these are skipped
365
+ */
366
+ decryptV2(encryptedData) {
367
+ debugLogger.decryptionAttemptStart('V2', hashForLog(encryptedData), encryptedData.length);
368
+ if (!this.encryptionKeyV2) {
369
+ debugLogger.missingKey('encryptionKeyV2', 'V2 decryption');
370
+ throw new Error('encryptionKeyV2 not set. Call setEncryptionKey or deriveEncryptionKeyFromWallet first.');
371
+ }
372
+ // Check schema version byte at position 8
373
+ const schemaVersion = encryptedData.length >= 9 ? encryptedData[8] : 0;
374
+ // Skip very old format UTXOs (no schema version)
375
+ if (schemaVersion < 0x01) {
376
+ debugLogger.decryptionFailure('V2', 'LEGACY_FORMAT_SKIPPED', 'Very old format UTXO without schema version byte - skipping', {
377
+ dataLength: encryptedData.length,
378
+ byte8Value: schemaVersion
379
+ });
380
+ return null;
381
+ }
382
+ // Determine byte offsets based on schema version
383
+ // Schema 0x01: [version(8)] + [schema(1)] + [IV(12)] + [authTag(16)] + [encrypted]
384
+ // Schema 0x02: [version(8)] + [schema(1)] + [recipientIdHash(8)] + [IV(12)] + [authTag(16)] + [encrypted]
385
+ let ivStart, ivEnd, authTagStart, authTagEnd, dataStart;
386
+ if (schemaVersion >= 0x02) {
387
+ // New format with recipient ID hash (8 bytes)
388
+ ivStart = 9 + EncryptionService.RECIPIENT_ID_LENGTH; // 17
389
+ ivEnd = ivStart + 12; // 29
390
+ authTagStart = ivEnd; // 29
391
+ authTagEnd = authTagStart + 16; // 45
392
+ dataStart = authTagEnd; // 45
393
+ }
394
+ else {
395
+ // Old format (schema 0x01) without recipient ID hash
396
+ ivStart = 9;
397
+ ivEnd = 21;
398
+ authTagStart = 21;
399
+ authTagEnd = 37;
400
+ dataStart = 37;
401
+ }
402
+ const iv = encryptedData.slice(ivStart, ivEnd);
403
+ const authTag = encryptedData.slice(authTagStart, authTagEnd);
404
+ const data = encryptedData.slice(dataStart);
405
+ // Use the full 32-byte V2 encryption key for AES-256
406
+ const key = Buffer.from(this.encryptionKeyV2);
407
+ // Use AES-256-GCM for authenticated decryption
408
+ // Noble ciphers expects ciphertext + authTag
409
+ const ciphertextWithTag = Buffer.concat([data, authTag]);
410
+ try {
411
+ const stream = gcm(key, iv);
412
+ const decrypted = Buffer.from(stream.decrypt(ciphertextWithTag));
413
+ debugLogger.decryptionSuccess('V2', decrypted.length);
414
+ return decrypted;
415
+ }
416
+ catch (error) {
417
+ const errMsg = error instanceof Error ? error.message : 'Unknown error';
418
+ debugLogger.decryptionFailure('V2', 'AES_GCM_FAILED', errMsg, {
419
+ ivHash: hashForLog(iv),
420
+ authTagHash: hashForLog(authTag),
421
+ dataLength: data.length,
422
+ schemaVersion
423
+ });
424
+ throw new Error('Failed to decrypt data. Invalid encryption key or corrupted data.');
425
+ }
426
+ }
427
+ /**
428
+ * Reset the encryption keys (mainly for testing purposes)
429
+ */
430
+ resetEncryptionKey() {
431
+ this.encryptionKeyV1 = null;
432
+ this.encryptionKeyV2 = null;
433
+ this.asymmetricSecretKey = null;
434
+ this.utxoPrivateKeyV1 = null;
435
+ this.utxoPrivateKeyV2 = null;
436
+ this.walletAddress = null;
437
+ }
438
+ /**
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.
441
+ * @param utxo The UTXO to encrypt (includes version property)
442
+ * @param recipientEncryptionKey Optional recipient X25519 public key for asymmetric encryption
443
+ * @returns The encrypted UTXO data as a Buffer
444
+ * @throws Error if the V2 encryption key has not been set
445
+ */
446
+ encryptUtxo(utxo, recipientEncryptionKey) {
447
+ if (!this.encryptionKeyV2) {
448
+ throw new Error('Encryption key not set. Call setEncryptionKey or deriveEncryptionKeyFromWallet first.');
449
+ }
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}`;
453
+ if (recipientEncryptionKey) {
454
+ return this.encryptAsymmetric(utxoString, recipientEncryptionKey);
455
+ }
456
+ // Always use V2 encryption format (which adds version byte 0x02 at the beginning)
457
+ return this.encrypt(utxoString);
458
+ }
459
+ // Deprecated, only used for testing now
460
+ encryptUtxoDecryptedDoNotUse(utxo) {
461
+ if (!this.encryptionKeyV2) {
462
+ throw new Error('Encryption key not set. Call setEncryptionKey or deriveEncryptionKeyFromWallet first.');
463
+ }
464
+ const utxoString = `${utxo.amount.toString()}|${utxo.blinding.toString()}|${utxo.index}|${utxo.mintAddress}`;
465
+ return this.encryptDecryptedDoNotUse(utxoString);
466
+ }
467
+ getEncryptionKeyVersion(encryptedData) {
468
+ const buffer = typeof encryptedData === 'string' ? Buffer.from(encryptedData, 'hex') : encryptedData;
469
+ const dataHash = hashForLog(buffer);
470
+ // Log the first 8 bytes (version prefix) for debugging
471
+ const prefixBytes = buffer.length >= 8 ? buffer.subarray(0, 8) : buffer;
472
+ const prefixHex = Buffer.from(prefixBytes).toString('hex');
473
+ debugLogger.versionPrefixBytes(prefixHex, buffer.length);
474
+ let version;
475
+ if (buffer.length >= 8 && buffer.subarray(0, 8).equals(EncryptionService.ENCRYPTION_VERSION_V2)) {
476
+ // V2 encryption format → V2 UTXO
477
+ version = 'v2';
478
+ }
479
+ else if (buffer.length >= 8 && buffer.subarray(0, 8).equals(EncryptionService.ENCRYPTION_VERSION_V3)) {
480
+ version = 'v3';
481
+ }
482
+ else {
483
+ // V1 encryption format → UTXO (legacy mode fallback)
484
+ version = 'v1';
485
+ const reason = buffer.length < 8
486
+ ? `Data too short (${buffer.length} bytes) to contain version prefix`
487
+ : `Unrecognized version prefix: ${prefixHex}`;
488
+ debugLogger.versionFallbackToLegacy(prefixHex, reason);
489
+ }
490
+ debugLogger.versionDetected(dataHash, version, buffer.length);
491
+ return version;
492
+ }
493
+ /**
494
+ * Check if the schema version at byte 9 matches the expected version.
495
+ * Returns null if schema version matches or data is legacy format (no schema version).
496
+ * Returns the found schema version if it doesn't match (for early termination).
497
+ * @param encryptedBuffer The encrypted data buffer
498
+ * @param encryptionVersion The encryption version (v1, v2, v3)
499
+ * @returns null if schema version matches or is legacy, otherwise the mismatched version byte
500
+ */
501
+ checkSchemaVersionMismatch(encryptedBuffer, encryptionVersion) {
502
+ // V1 doesn't have schema version byte, skip check
503
+ if (encryptionVersion === 'v1') {
504
+ return null;
505
+ }
506
+ // Check if we have enough bytes to contain schema version
507
+ if (encryptedBuffer.length < 9) {
508
+ return null; // Too short, let decryption handle the error
509
+ }
510
+ const schemaVersionByte = encryptedBuffer[8];
511
+ const expectedSchemaVersion = EncryptionService.SIGNATURE_SCHEMA_VERSION[0];
512
+ // If schema version matches, proceed with decryption
513
+ if (schemaVersionByte === expectedSchemaVersion) {
514
+ return null;
515
+ }
516
+ // For early termination, we only skip if the schema version byte is clearly
517
+ // a FUTURE schema version (greater than current). This maintains backward
518
+ // compatibility with legacy data that doesn't have a schema version byte.
519
+ //
520
+ // Legacy format detection:
521
+ // - Legacy V2/V3 data has IV or ephemeral pubkey bytes starting at position 8
522
+ // - These are random/crypto bytes that could have any value
523
+ // - We can't reliably distinguish legacy data from schema version bytes
524
+ //
525
+ // Conservative approach:
526
+ // - Only skip if byte 8 > current schema version (clearly a future version)
527
+ // - This handles the common case of different apps using different schema versions
528
+ // - Legacy data (where byte 8 could be anything) will attempt decryption and fail naturally
529
+ if (schemaVersionByte > expectedSchemaVersion) {
530
+ return schemaVersionByte;
531
+ }
532
+ return null; // Assume legacy format or compatible version, attempt decryption
533
+ }
534
+ /**
535
+ * Decrypt an encrypted UTXO and parse it to a Utxo instance
536
+ * Automatically detects the UTXO version based on the encryption format
537
+ * @param encryptedData The encrypted UTXO data
538
+ * @param keypair The UTXO keypair to use for the decrypted UTXO
539
+ * @param lightWasm Optional LightWasm instance. If not provided, a new one will be created
540
+ * @param walletKeypair Optional wallet keypair for V1 backward compatibility
541
+ * @returns Promise resolving to the decrypted Utxo instance, or null if schema version mismatch
542
+ * @throws Error if the encryption key has not been set or if decryption fails
543
+ */
544
+ async decryptUtxo(encryptedData, lightWasm) {
545
+ // Convert hex string to Buffer if needed
546
+ const encryptedBuffer = typeof encryptedData === 'string'
547
+ ? Buffer.from(encryptedData, 'hex')
548
+ : encryptedData;
549
+ // O(1) Early termination: Check recipient ID hash BEFORE any expensive operations
550
+ // This is the fastest way to skip UTXOs that don't belong to this wallet
551
+ if (!this.shouldAttemptDecryption(encryptedBuffer)) {
552
+ debugLogger.recipientIdMismatch(hashForLog(encryptedBuffer));
553
+ return null;
554
+ }
555
+ // Detect UTXO version based on encryption format
556
+ let utxoVersion = this.getEncryptionKeyVersion(encryptedBuffer);
557
+ const originalVersion = utxoVersion;
558
+ // Skip V1 format entirely - V1 never had schema versions and is considered legacy
559
+ // No backward compatibility needed for V1 format UTXOs
560
+ if (utxoVersion === 'v1') {
561
+ debugLogger.decryptionFailure('V1', 'LEGACY_FORMAT_SKIPPED', 'V1 format UTXO - skipping (no backward compatibility)');
562
+ return null;
563
+ }
564
+ // Early termination: Check schema version at byte 9 BEFORE attempting decryption
565
+ // This provides O(1) skip decision for UTXOs with incompatible schema versions
566
+ const mismatchedSchemaVersion = this.checkSchemaVersionMismatch(encryptedBuffer, utxoVersion);
567
+ if (mismatchedSchemaVersion !== null) {
568
+ const expectedSchemaVersion = EncryptionService.SIGNATURE_SCHEMA_VERSION[0];
569
+ debugLogger.schemaVersionMismatch(mismatchedSchemaVersion, expectedSchemaVersion, hashForLog(encryptedBuffer));
570
+ return null;
571
+ }
572
+ let decrypted;
573
+ if (utxoVersion === 'v3') {
574
+ decrypted = this.decryptV3(encryptedBuffer);
575
+ // V3 also uses V2 private keys for the UTXO logic
576
+ utxoVersion = 'v2';
577
+ }
578
+ else {
579
+ // For V2 format, use decrypt() which calls decryptV2()
580
+ decrypted = this.decrypt(encryptedBuffer);
581
+ }
582
+ // Handle legacy format UTXOs (without schema version byte) - skip them
583
+ if (decrypted === null) {
584
+ debugLogger.decryptionFailure(originalVersion, 'LEGACY_FORMAT_SKIPPED', 'Old format UTXO without schema version byte - returning null');
585
+ return null;
586
+ }
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');
605
+ }
606
+ // Get or create a LightWasm instance
607
+ const wasmInstance = lightWasm || await WasmFactory.getInstance();
608
+ const privateKey = this.getUtxoPrivateKeyWithVersion(utxoVersion);
609
+ // Create a Utxo instance with the detected version
610
+ const utxo = new Utxo({
611
+ lightWasm: wasmInstance,
612
+ amount: amount,
613
+ blinding: blinding,
614
+ keypair: new UtxoKeypair(privateKey, wasmInstance),
615
+ index: Number(index),
616
+ mintAddress: mintAddress,
617
+ version: utxoVersion
618
+ });
619
+ // Log UTXO metadata after successful decryption
620
+ const commitment = await utxo.getCommitment();
621
+ debugLogger.utxoDecrypted(hashForLog(Buffer.from(commitment)), mintAddress, encryptedBuffer.length, index, originalVersion);
622
+ return utxo;
623
+ }
624
+ getUtxoPrivateKeyWithVersion(version) {
625
+ if (version === 'v1') {
626
+ return this.getUtxoPrivateKeyV1();
627
+ }
628
+ return this.getUtxoPrivateKeyV2();
629
+ }
630
+ deriveUtxoPrivateKey(encryptedData) {
631
+ if (encryptedData && this.getEncryptionKeyVersion(encryptedData) === 'v2') {
632
+ return this.getUtxoPrivateKeyWithVersion('v2');
633
+ }
634
+ if (encryptedData && this.getEncryptionKeyVersion(encryptedData) === 'v3') {
635
+ return this.getUtxoPrivateKeyWithVersion('v2');
636
+ }
637
+ return this.getUtxoPrivateKeyWithVersion('v1');
638
+ }
639
+ hasUtxoPrivateKeyWithVersion(version) {
640
+ if (version === 'v1') {
641
+ return !!this.utxoPrivateKeyV1;
642
+ }
643
+ return !!this.utxoPrivateKeyV2;
644
+ }
645
+ /**
646
+ * Get the cached V1 UTXO private key
647
+ * @returns A private key in hex format that can be used to create a UTXO keypair
648
+ * @throws Error if V1 encryption key has not been set
649
+ */
650
+ getUtxoPrivateKeyV1() {
651
+ if (!this.utxoPrivateKeyV1) {
652
+ throw new Error('Encryption key not set. Call setEncryptionKey or deriveEncryptionKeyFromWallet first.');
653
+ }
654
+ return this.utxoPrivateKeyV1;
655
+ }
656
+ /**
657
+ * Get the cached V2 UTXO private key
658
+ * @returns A private key in hex format that can be used to create a UTXO keypair
659
+ * @throws Error if V2 encryption key has not been set
660
+ */
661
+ getUtxoPrivateKeyV2() {
662
+ if (!this.utxoPrivateKeyV2) {
663
+ throw new Error('Encryption key not set. Call setEncryptionKey or deriveEncryptionKeyFromWallet first.');
664
+ }
665
+ return this.utxoPrivateKeyV2;
666
+ }
667
+ }
668
+ export function serializeProofAndExtData(proof, extData, isSpl = false) {
669
+ // Create the ExtDataMinified object for the program call (only extAmount and fee)
670
+ const extDataMinified = {
671
+ extAmount: extData.extAmount,
672
+ fee: extData.fee
673
+ };
674
+ // Use the appropriate discriminator based on whether this is SPL or native SOL
675
+ const discriminator = isSpl ? TRANSACT_SPL_IX_DISCRIMINATOR : TRANSACT_IX_DISCRIMINATOR;
676
+ // Use the same serialization approach as deposit script
677
+ const instructionData = Buffer.concat([
678
+ discriminator,
679
+ // Serialize proof
680
+ Buffer.from(proof.proofA),
681
+ Buffer.from(proof.proofB),
682
+ Buffer.from(proof.proofC),
683
+ Buffer.from(proof.root),
684
+ Buffer.from(proof.publicAmount),
685
+ Buffer.from(proof.extDataHash),
686
+ Buffer.from(proof.inputNullifiers[0]),
687
+ Buffer.from(proof.inputNullifiers[1]),
688
+ Buffer.from(proof.outputCommitments[0]),
689
+ Buffer.from(proof.outputCommitments[1]),
690
+ // Serialize ExtDataMinified (only extAmount and fee)
691
+ Buffer.from(new BN(extDataMinified.extAmount).toTwos(64).toArray('le', 8)),
692
+ Buffer.from(new BN(extDataMinified.fee).toArray('le', 8)),
693
+ // Serialize encrypted outputs as separate parameters
694
+ Buffer.from(new BN(extData.encryptedOutput1.length).toArray('le', 4)),
695
+ extData.encryptedOutput1,
696
+ Buffer.from(new BN(extData.encryptedOutput2.length).toArray('le', 4)),
697
+ extData.encryptedOutput2,
698
+ ]);
699
+ return instructionData;
700
+ }