@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 +11 -11
- package/dist/config.d.ts +11 -0
- package/dist/config.js +31 -1
- package/dist/deposit.js +35 -8
- package/dist/depositSPL.js +40 -8
- package/dist/models/utxo.d.ts +1 -1
- package/dist/models/utxo.js +13 -1
- package/dist/utils/debug-logger.d.ts +1 -1
- package/dist/utils/debug-logger.js +9 -3
- package/dist/utils/encryption.d.ts +26 -3
- package/dist/utils/encryption.js +183 -31
- package/dist/utils/retry.d.ts +21 -0
- package/dist/utils/retry.js +30 -0
- package/dist/withdraw.js +14 -8
- package/dist/withdrawSPL.js +13 -7
- package/package.json +1 -1
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 (!
|
|
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
|
-
|
|
417
|
+
const maxRetries = 10;
|
|
396
418
|
const encryptedOutputStr = Buffer.from(encryptedOutput1).toString("hex");
|
|
397
|
-
|
|
419
|
+
const start = Date.now();
|
|
398
420
|
while (true) {
|
|
399
421
|
logger.info("Confirming transaction..");
|
|
400
422
|
logger.debug(`retryTimes: ${retryTimes}`);
|
|
401
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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) {
|
package/dist/depositSPL.js
CHANGED
|
@@ -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
|
-
|
|
472
|
+
const maxRetries = 10;
|
|
446
473
|
const encryptedOutputStr = Buffer.from(encryptedOutput1).toString("hex");
|
|
447
|
-
|
|
474
|
+
const start = Date.now();
|
|
448
475
|
while (true) {
|
|
449
476
|
logger.info("Confirming transaction..");
|
|
450
477
|
logger.debug(`retryTimes: ${retryTimes}`);
|
|
451
|
-
|
|
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
|
-
|
|
485
|
+
const url = RELAYER_API_URL +
|
|
454
486
|
"/utxos/check/" +
|
|
455
487
|
encryptedOutputStr +
|
|
456
488
|
"?token=" +
|
|
457
489
|
token.name;
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/models/utxo.d.ts
CHANGED
|
@@ -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, //
|
|
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;
|
package/dist/models/utxo.js
CHANGED
|
@@ -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 =
|
|
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 (
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|
107
|
-
*
|
|
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
|
package/dist/utils/encryption.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
440
|
-
*
|
|
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
|
-
//
|
|
451
|
-
//
|
|
452
|
-
const
|
|
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.
|
|
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
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
266
|
+
const maxRetries = 10;
|
|
266
267
|
const encryptedOutputStr = Buffer.from(encryptedOutput1).toString("hex");
|
|
267
|
-
|
|
268
|
+
const start = Date.now();
|
|
268
269
|
while (true) {
|
|
269
270
|
logger.info("Confirming transaction..");
|
|
270
271
|
logger.debug(`retryTimes: ${retryTimes}`);
|
|
271
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
}
|
package/dist/withdrawSPL.js
CHANGED
|
@@ -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
|
-
|
|
301
|
+
const maxRetries = 10;
|
|
301
302
|
const encryptedOutputStr = Buffer.from(encryptedOutput1).toString("hex");
|
|
302
|
-
|
|
303
|
+
const start = Date.now();
|
|
303
304
|
while (true) {
|
|
304
305
|
logger.info("Confirming transaction..");
|
|
305
306
|
logger.debug(`retryTimes: ${retryTimes}`);
|
|
306
|
-
|
|
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
|
-
|
|
314
|
+
const res = await fetch(RELAYER_API_URL +
|
|
309
315
|
"/utxos/check/" +
|
|
310
316
|
encryptedOutputStr +
|
|
311
317
|
"?token=" +
|
|
312
318
|
token.name);
|
|
313
|
-
|
|
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
|
-
|
|
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
|
}
|