nova-privacy-sdk 1.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 (69) hide show
  1. package/.github/workflows/npm-publish.yml +55 -0
  2. package/PUBLISH.md +122 -0
  3. package/README.md +177 -0
  4. package/__tests__/e2e.test.ts +56 -0
  5. package/__tests__/e2espl.test.ts +73 -0
  6. package/__tests__/encryption.test.ts +1635 -0
  7. package/circuit2/transaction2.wasm +0 -0
  8. package/circuit2/transaction2.zkey +0 -0
  9. package/dist/config.d.ts +9 -0
  10. package/dist/config.js +12 -0
  11. package/dist/deposit.d.ts +18 -0
  12. package/dist/deposit.js +392 -0
  13. package/dist/depositSPL.d.ts +20 -0
  14. package/dist/depositSPL.js +448 -0
  15. package/dist/exportUtils.d.ts +11 -0
  16. package/dist/exportUtils.js +11 -0
  17. package/dist/getUtxos.d.ts +29 -0
  18. package/dist/getUtxos.js +294 -0
  19. package/dist/getUtxosSPL.d.ts +33 -0
  20. package/dist/getUtxosSPL.js +395 -0
  21. package/dist/index.d.ts +125 -0
  22. package/dist/index.js +302 -0
  23. package/dist/models/keypair.d.ts +26 -0
  24. package/dist/models/keypair.js +43 -0
  25. package/dist/models/utxo.d.ts +49 -0
  26. package/dist/models/utxo.js +85 -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 +31 -0
  30. package/dist/utils/constants.js +62 -0
  31. package/dist/utils/encryption.d.ts +107 -0
  32. package/dist/utils/encryption.js +376 -0
  33. package/dist/utils/logger.d.ts +9 -0
  34. package/dist/utils/logger.js +35 -0
  35. package/dist/utils/merkle_tree.d.ts +92 -0
  36. package/dist/utils/merkle_tree.js +186 -0
  37. package/dist/utils/node-shim.d.ts +5 -0
  38. package/dist/utils/node-shim.js +5 -0
  39. package/dist/utils/prover.d.ts +36 -0
  40. package/dist/utils/prover.js +147 -0
  41. package/dist/utils/utils.d.ts +69 -0
  42. package/dist/utils/utils.js +182 -0
  43. package/dist/withdraw.d.ts +21 -0
  44. package/dist/withdraw.js +270 -0
  45. package/dist/withdrawSPL.d.ts +23 -0
  46. package/dist/withdrawSPL.js +306 -0
  47. package/package.json +77 -0
  48. package/setup-git.sh +51 -0
  49. package/setup-github.sh +36 -0
  50. package/src/config.ts +22 -0
  51. package/src/deposit.ts +487 -0
  52. package/src/depositSPL.ts +567 -0
  53. package/src/exportUtils.ts +13 -0
  54. package/src/getUtxos.ts +396 -0
  55. package/src/getUtxosSPL.ts +528 -0
  56. package/src/index.ts +350 -0
  57. package/src/models/keypair.ts +52 -0
  58. package/src/models/utxo.ts +106 -0
  59. package/src/utils/address_lookup_table.ts +78 -0
  60. package/src/utils/constants.ts +84 -0
  61. package/src/utils/encryption.ts +464 -0
  62. package/src/utils/logger.ts +42 -0
  63. package/src/utils/merkle_tree.ts +207 -0
  64. package/src/utils/node-shim.ts +6 -0
  65. package/src/utils/prover.ts +222 -0
  66. package/src/utils/utils.ts +242 -0
  67. package/src/withdraw.ts +332 -0
  68. package/src/withdrawSPL.ts +394 -0
  69. package/tsconfig.json +28 -0
@@ -0,0 +1,270 @@
1
+ import { LAMPORTS_PER_SOL } from '@solana/web3.js';
2
+ import BN from 'bn.js';
3
+ import { Buffer } from 'buffer';
4
+ import { Keypair as UtxoKeypair } from './models/keypair.js';
5
+ import { Utxo } from './models/utxo.js';
6
+ import { parseProofToBytesArray, parseToBytesArray, prove } from './utils/prover.js';
7
+ import { ALT_ADDRESS, FEE_RECIPIENT, FIELD_SIZE, RELAYER_API_URL, MERKLE_TREE_DEPTH } from './utils/constants.js';
8
+ import { serializeProofAndExtData } from './utils/encryption.js';
9
+ import { fetchMerkleProof, findNullifierPDAs, getExtDataHash, getProgramAccounts, queryRemoteTreeState, findCrossCheckNullifierPDAs } from './utils/utils.js';
10
+ import { getUtxos } from './getUtxos.js';
11
+ import { logger } from './utils/logger.js';
12
+ import { getConfig } from './config.js';
13
+ // Indexer API endpoint
14
+ // Function to submit withdraw request to indexer backend
15
+ async function submitWithdrawToIndexer(params) {
16
+ try {
17
+ const response = await fetch(`${RELAYER_API_URL}/withdraw`, {
18
+ method: 'POST',
19
+ headers: {
20
+ 'Content-Type': 'application/json',
21
+ },
22
+ body: JSON.stringify(params)
23
+ });
24
+ if (!response.ok) {
25
+ const errorData = await response.json();
26
+ throw new Error(errorData.error);
27
+ }
28
+ const result = await response.json();
29
+ logger.debug('Withdraw request submitted successfully!');
30
+ logger.debug('Response:', result);
31
+ return result.signature;
32
+ }
33
+ catch (error) {
34
+ logger.debug('Failed to submit withdraw request to indexer:', typeof error, error);
35
+ throw error;
36
+ }
37
+ }
38
+ export async function withdraw({ recipient, lightWasm, storage, publicKey, connection, amount_in_lamports, encryptionService, keyBasePath }) {
39
+ let fee_in_lamports = amount_in_lamports * (await getConfig('withdraw_fee_rate')) + LAMPORTS_PER_SOL * (await getConfig('withdraw_rent_fee'));
40
+ amount_in_lamports -= fee_in_lamports;
41
+ let isPartial = false;
42
+ logger.debug('Encryption key generated from user keypair');
43
+ const { treeAccount, treeTokenAccount, globalConfigAccount } = getProgramAccounts();
44
+ // Get current tree state
45
+ const { root, nextIndex: currentNextIndex } = await queryRemoteTreeState();
46
+ logger.debug(`Using tree root: ${root}`);
47
+ logger.debug(`New UTXOs will be inserted at indices: ${currentNextIndex} and ${currentNextIndex + 1}`);
48
+ // Generate a deterministic private key derived from the wallet keypair
49
+ const utxoPrivateKey = encryptionService.deriveUtxoPrivateKey();
50
+ // Create a UTXO keypair that will be used for all inputs and outputs
51
+ const utxoKeypair = new UtxoKeypair(utxoPrivateKey, lightWasm);
52
+ logger.debug('Using wallet-derived UTXO keypair for withdrawal');
53
+ // Generate a deterministic private key derived from the wallet keypair (V2)
54
+ const utxoPrivateKeyV2 = encryptionService.getUtxoPrivateKeyV2();
55
+ const utxoKeypairV2 = new UtxoKeypair(utxoPrivateKeyV2, lightWasm);
56
+ // Fetch existing UTXOs for this user
57
+ logger.debug('\nFetching existing UTXOs...');
58
+ const unspentUtxos = await getUtxos({ connection, publicKey, encryptionService, storage });
59
+ logger.debug(`Found ${unspentUtxos.length} total UTXOs`);
60
+ // Calculate and log total unspent UTXO balance
61
+ const totalUnspentBalance = unspentUtxos.reduce((sum, utxo) => sum.add(utxo.amount), new BN(0));
62
+ logger.debug(`Total unspent UTXO balance before: ${totalUnspentBalance.toString()} lamports (${totalUnspentBalance.toNumber() / 1e9} SOL)`);
63
+ if (unspentUtxos.length < 1) {
64
+ throw new Error('Need at least 1 unspent UTXO to perform a withdrawal');
65
+ }
66
+ // Sort UTXOs by amount in descending order to use the largest ones first
67
+ unspentUtxos.sort((a, b) => b.amount.cmp(a.amount));
68
+ // Use the largest UTXO as first input, and either second largest UTXO or dummy UTXO as second input
69
+ const firstInput = unspentUtxos[0];
70
+ const secondInput = unspentUtxos.length > 1 ? unspentUtxos[1] : new Utxo({
71
+ lightWasm,
72
+ keypair: utxoKeypair,
73
+ amount: '0'
74
+ });
75
+ const inputs = [firstInput, secondInput];
76
+ logger.debug(`firstInput index: ${firstInput.index}, commitment: ${firstInput.getCommitment()}`);
77
+ logger.debug(`secondInput index: ${secondInput.index}, commitment: ${secondInput.getCommitment()}`);
78
+ const totalInputAmount = firstInput.amount.add(secondInput.amount);
79
+ logger.debug(`Using UTXO with amount: ${firstInput.amount.toString()} and ${secondInput.amount.gt(new BN(0)) ? 'second UTXO with amount: ' + secondInput.amount.toString() : 'dummy UTXO'}`);
80
+ if (totalInputAmount.toNumber() === 0) {
81
+ throw new Error('no balance');
82
+ }
83
+ if (totalInputAmount.lt(new BN(amount_in_lamports + fee_in_lamports))) {
84
+ isPartial = true;
85
+ amount_in_lamports = totalInputAmount.toNumber();
86
+ amount_in_lamports -= fee_in_lamports;
87
+ }
88
+ // Calculate the change amount (what's left after withdrawal and fee)
89
+ const changeAmount = totalInputAmount.sub(new BN(amount_in_lamports)).sub(new BN(fee_in_lamports));
90
+ logger.debug(`Withdrawing ${amount_in_lamports} lamports with ${fee_in_lamports} fee, ${changeAmount.toString()} as change`);
91
+ // Get Merkle proofs for both input UTXOs
92
+ const inputMerkleProofs = await Promise.all(inputs.map(async (utxo, index) => {
93
+ // For dummy UTXO (amount is 0), use a zero-filled proof
94
+ if (utxo.amount.eq(new BN(0))) {
95
+ return {
96
+ pathElements: [...new Array(MERKLE_TREE_DEPTH).fill("0")],
97
+ pathIndices: Array(MERKLE_TREE_DEPTH).fill(0)
98
+ };
99
+ }
100
+ // For real UTXOs, fetch the proof from API
101
+ const commitment = await utxo.getCommitment();
102
+ return fetchMerkleProof(commitment);
103
+ }));
104
+ // Extract path elements and indices
105
+ const inputMerklePathElements = inputMerkleProofs.map(proof => proof.pathElements);
106
+ const inputMerklePathIndices = inputs.map(utxo => utxo.index || 0);
107
+ // Create outputs: first output is change, second is dummy (required by protocol)
108
+ const outputs = [
109
+ new Utxo({
110
+ lightWasm,
111
+ amount: changeAmount.toString(),
112
+ keypair: utxoKeypairV2,
113
+ index: currentNextIndex
114
+ }), // Change output
115
+ new Utxo({
116
+ lightWasm,
117
+ amount: '0',
118
+ keypair: utxoKeypairV2,
119
+ index: currentNextIndex + 1
120
+ }) // Empty UTXO
121
+ ];
122
+ // For withdrawals, extAmount is negative (funds leaving the system)
123
+ const extAmount = -amount_in_lamports;
124
+ const publicAmountForCircuit = new BN(extAmount).sub(new BN(fee_in_lamports)).add(FIELD_SIZE).mod(FIELD_SIZE);
125
+ logger.debug(`Public amount calculation: (${extAmount} - ${fee_in_lamports} + FIELD_SIZE) % FIELD_SIZE = ${publicAmountForCircuit.toString()}`);
126
+ // Verify this matches the circuit balance equation: sumIns + publicAmount = sumOuts
127
+ const sumIns = inputs.reduce((sum, input) => sum.add(input.amount), new BN(0));
128
+ const sumOuts = outputs.reduce((sum, output) => sum.add(output.amount), new BN(0));
129
+ logger.debug(`Circuit balance check: sumIns(${sumIns.toString()}) + publicAmount(${publicAmountForCircuit.toString()}) should equal sumOuts(${sumOuts.toString()})`);
130
+ // Convert to circuit-compatible format
131
+ const publicAmountCircuitResult = sumIns.add(publicAmountForCircuit).mod(FIELD_SIZE);
132
+ logger.debug(`Balance verification: ${sumIns.toString()} + ${publicAmountForCircuit.toString()} (mod FIELD_SIZE) = ${publicAmountCircuitResult.toString()}`);
133
+ logger.debug(`Expected sum of outputs: ${sumOuts.toString()}`);
134
+ logger.debug(`Balance equation satisfied: ${publicAmountCircuitResult.eq(sumOuts)}`);
135
+ // Generate nullifiers and commitments
136
+ const inputNullifiers = await Promise.all(inputs.map(x => x.getNullifier()));
137
+ const outputCommitments = await Promise.all(outputs.map(x => x.getCommitment()));
138
+ // Save original commitment and nullifier values for verification
139
+ logger.debug('\n=== UTXO VALIDATION ===');
140
+ logger.debug('Output 0 Commitment:', outputCommitments[0]);
141
+ logger.debug('Output 1 Commitment:', outputCommitments[1]);
142
+ // Encrypt the UTXO data using a compact format that includes the keypair
143
+ logger.debug('\nEncrypting UTXOs with keypair data...');
144
+ const encryptedOutput1 = encryptionService.encryptUtxo(outputs[0]);
145
+ const encryptedOutput2 = encryptionService.encryptUtxo(outputs[1]);
146
+ logger.debug(`\nOutput[0] (change):`);
147
+ await outputs[0].log();
148
+ logger.debug(`\nOutput[1] (empty):`);
149
+ await outputs[1].log();
150
+ logger.debug(`Encrypted output 1: ${encryptedOutput1.toString('hex')}`);
151
+ logger.debug(`Encrypted output 2: ${encryptedOutput2.toString('hex')}`);
152
+ logger.debug(`\nEncrypted output 1 size: ${encryptedOutput1.length} bytes`);
153
+ logger.debug(`Encrypted output 2 size: ${encryptedOutput2.length} bytes`);
154
+ logger.debug(`Total encrypted outputs size: ${encryptedOutput1.length + encryptedOutput2.length} bytes`);
155
+ // Test decryption to verify commitment values match
156
+ logger.debug('\n=== TESTING DECRYPTION ===');
157
+ logger.debug('Decrypting output 1 to verify commitment matches...');
158
+ const decryptedUtxo1 = await encryptionService.decryptUtxo(encryptedOutput1, lightWasm);
159
+ const decryptedCommitment1 = await decryptedUtxo1.getCommitment();
160
+ logger.debug('Original commitment:', outputCommitments[0]);
161
+ logger.debug('Decrypted commitment:', decryptedCommitment1);
162
+ logger.debug('Commitment matches:', outputCommitments[0] === decryptedCommitment1);
163
+ // Create the withdrawal ExtData with real encrypted outputs
164
+ const extData = {
165
+ // it can be any address
166
+ recipient,
167
+ extAmount: new BN(extAmount),
168
+ encryptedOutput1: encryptedOutput1,
169
+ encryptedOutput2: encryptedOutput2,
170
+ fee: new BN(fee_in_lamports),
171
+ feeRecipient: FEE_RECIPIENT,
172
+ mintAddress: inputs[0].mintAddress
173
+ };
174
+ // Calculate the extDataHash with the encrypted outputs
175
+ const calculatedExtDataHash = getExtDataHash(extData);
176
+ // Create the input for the proof generation
177
+ const input = {
178
+ // Common transaction data
179
+ root: root,
180
+ inputNullifier: inputNullifiers,
181
+ outputCommitment: outputCommitments,
182
+ publicAmount: publicAmountForCircuit.toString(),
183
+ extDataHash: calculatedExtDataHash,
184
+ // Input UTXO data (UTXOs being spent)
185
+ inAmount: inputs.map(x => x.amount.toString(10)),
186
+ inPrivateKey: inputs.map(x => x.keypair.privkey),
187
+ inBlinding: inputs.map(x => x.blinding.toString(10)),
188
+ inPathIndices: inputMerklePathIndices,
189
+ inPathElements: inputMerklePathElements,
190
+ // Output UTXO data (UTXOs being created)
191
+ outAmount: outputs.map(x => x.amount.toString(10)),
192
+ outBlinding: outputs.map(x => x.blinding.toString(10)),
193
+ outPubkey: outputs.map(x => x.keypair.pubkey),
194
+ // new mint address
195
+ mintAddress: inputs[0].mintAddress
196
+ };
197
+ logger.info('generating ZK proof...');
198
+ // Generate the zero-knowledge proof
199
+ const { proof, publicSignals } = await prove(input, keyBasePath);
200
+ // Parse the proof and public signals into byte arrays
201
+ const proofInBytes = parseProofToBytesArray(proof);
202
+ const inputsInBytes = parseToBytesArray(publicSignals);
203
+ // Create the proof object to submit to the program
204
+ const proofToSubmit = {
205
+ proofA: proofInBytes.proofA,
206
+ proofB: proofInBytes.proofB.flat(),
207
+ proofC: proofInBytes.proofC,
208
+ root: inputsInBytes[0],
209
+ publicAmount: inputsInBytes[1],
210
+ extDataHash: inputsInBytes[2],
211
+ inputNullifiers: [
212
+ inputsInBytes[3],
213
+ inputsInBytes[4]
214
+ ],
215
+ outputCommitments: [
216
+ inputsInBytes[5],
217
+ inputsInBytes[6]
218
+ ],
219
+ };
220
+ // Find PDAs for nullifiers and commitments
221
+ const { nullifier0PDA, nullifier1PDA } = findNullifierPDAs(proofToSubmit);
222
+ const { nullifier2PDA, nullifier3PDA } = findCrossCheckNullifierPDAs(proofToSubmit);
223
+ // Serialize the proof and extData
224
+ const serializedProof = serializeProofAndExtData(proofToSubmit, extData);
225
+ logger.debug(`Total instruction data size: ${serializedProof.length} bytes`);
226
+ // Prepare withdraw parameters for indexer backend
227
+ const withdrawParams = {
228
+ serializedProof: serializedProof.toString('base64'),
229
+ treeAccount: treeAccount.toString(),
230
+ nullifier0PDA: nullifier0PDA.toString(),
231
+ nullifier1PDA: nullifier1PDA.toString(),
232
+ nullifier2PDA: nullifier2PDA.toString(),
233
+ nullifier3PDA: nullifier3PDA.toString(),
234
+ treeTokenAccount: treeTokenAccount.toString(),
235
+ globalConfigAccount: globalConfigAccount.toString(),
236
+ recipient: recipient.toString(),
237
+ feeRecipientAccount: FEE_RECIPIENT.toString(),
238
+ extAmount: extAmount,
239
+ encryptedOutput1: encryptedOutput1.toString('base64'),
240
+ encryptedOutput2: encryptedOutput2.toString('base64'),
241
+ fee: fee_in_lamports,
242
+ lookupTableAddress: ALT_ADDRESS.toString(),
243
+ senderAddress: publicKey.toString()
244
+ };
245
+ logger.debug('Prepared withdraw parameters for indexer backend');
246
+ // Submit to indexer backend instead of directly to Solana
247
+ logger.info('submitting transaction to relayer...');
248
+ const signature = await submitWithdrawToIndexer(withdrawParams);
249
+ // Wait a moment for the transaction to be confirmed
250
+ logger.info('waiting for transaction confirmation...');
251
+ let retryTimes = 0;
252
+ let itv = 2;
253
+ const encryptedOutputStr = Buffer.from(encryptedOutput1).toString('hex');
254
+ let start = Date.now();
255
+ while (true) {
256
+ logger.info(`retryTimes: ${retryTimes}`);
257
+ await new Promise(resolve => setTimeout(resolve, itv * 1000));
258
+ logger.info('Fetching updated tree state...');
259
+ let res = await fetch(RELAYER_API_URL + '/utxos/check/' + encryptedOutputStr);
260
+ let resJson = await res.json();
261
+ logger.debug('resJson:', resJson);
262
+ if (resJson.exists) {
263
+ return { isPartial, tx: signature, recipient: recipient.toString(), amount_in_lamports, fee_in_lamports };
264
+ }
265
+ if (retryTimes >= 10) {
266
+ throw new Error('Refresh the page to see latest balance.');
267
+ }
268
+ retryTimes++;
269
+ }
270
+ }
@@ -0,0 +1,23 @@
1
+ import { Connection, PublicKey } from '@solana/web3.js';
2
+ import * as hasher from '@lightprotocol/hasher.rs';
3
+ import { EncryptionService } from './utils/encryption.js';
4
+ type WithdrawParams = {
5
+ publicKey: PublicKey;
6
+ connection: Connection;
7
+ base_units?: number;
8
+ amount?: number;
9
+ keyBasePath: string;
10
+ encryptionService: EncryptionService;
11
+ lightWasm: hasher.LightWasm;
12
+ recipient: PublicKey;
13
+ mintAddress: PublicKey | string;
14
+ storage: Storage;
15
+ };
16
+ export declare function withdrawSPL({ recipient, lightWasm, storage, publicKey, connection, base_units, amount, encryptionService, keyBasePath, mintAddress }: WithdrawParams): Promise<{
17
+ isPartial: boolean;
18
+ tx: string;
19
+ recipient: string;
20
+ base_units: number;
21
+ fee_base_units: number;
22
+ }>;
23
+ export {};
@@ -0,0 +1,306 @@
1
+ import { PublicKey } from '@solana/web3.js';
2
+ import BN from 'bn.js';
3
+ import { Buffer } from 'buffer';
4
+ import { Keypair as UtxoKeypair } from './models/keypair.js';
5
+ import { Utxo } from './models/utxo.js';
6
+ import { parseProofToBytesArray, parseToBytesArray, prove } from './utils/prover.js';
7
+ import { ALT_ADDRESS, FEE_RECIPIENT, FIELD_SIZE, RELAYER_API_URL, MERKLE_TREE_DEPTH, PROGRAM_ID, tokens } from './utils/constants.js';
8
+ import { serializeProofAndExtData } from './utils/encryption.js';
9
+ import { fetchMerkleProof, findNullifierPDAs, getProgramAccounts, queryRemoteTreeState, findCrossCheckNullifierPDAs, getMintAddressField, getExtDataHash, getTreeAccountForToken } from './utils/utils.js';
10
+ import { getUtxosSPL } from './getUtxosSPL.js';
11
+ import { logger } from './utils/logger.js';
12
+ import { getConfig } from './config.js';
13
+ import { getAssociatedTokenAddressSync, getMint } from '@solana/spl-token';
14
+ // Indexer API endpoint
15
+ // Function to submit withdraw request to indexer backend
16
+ async function submitWithdrawToIndexer(params) {
17
+ try {
18
+ const response = await fetch(`${RELAYER_API_URL}/withdraw/spl`, {
19
+ method: 'POST',
20
+ headers: {
21
+ 'Content-Type': 'application/json',
22
+ },
23
+ body: JSON.stringify(params)
24
+ });
25
+ if (!response.ok) {
26
+ const errorData = await response.json();
27
+ throw new Error(errorData.error);
28
+ }
29
+ const result = await response.json();
30
+ logger.debug('Withdraw request submitted successfully!');
31
+ logger.debug('Response:', result);
32
+ return result.signature;
33
+ }
34
+ catch (error) {
35
+ logger.debug('Failed to submit withdraw request to indexer:', typeof error, error);
36
+ throw error;
37
+ }
38
+ }
39
+ export async function withdrawSPL({ recipient, lightWasm, storage, publicKey, connection, base_units, amount, encryptionService, keyBasePath, mintAddress }) {
40
+ if (typeof mintAddress == 'string') {
41
+ mintAddress = new PublicKey(mintAddress);
42
+ }
43
+ let token = tokens.find(t => t.pubkey.toString() == mintAddress.toString());
44
+ if (!token) {
45
+ throw new Error('token not found: ' + mintAddress.toString());
46
+ }
47
+ if (amount) {
48
+ base_units = amount * token.units_per_token;
49
+ }
50
+ if (!base_units) {
51
+ throw new Error('You must input at leaset one of "base_units" or "amount"');
52
+ }
53
+ let mintInfo = await getMint(connection, token.pubkey);
54
+ let units_per_token = 10 ** mintInfo.decimals;
55
+ let withdraw_fee_rate = await getConfig('withdraw_fee_rate');
56
+ let withdraw_rent_fees = await getConfig('rent_fees');
57
+ let token_rent_fee = withdraw_rent_fees[token.name];
58
+ if (!token_rent_fee) {
59
+ throw new Error('can not find token_rent_fee for ' + token.name);
60
+ }
61
+ let fee_base_units = Math.floor(base_units * withdraw_fee_rate + units_per_token * token_rent_fee);
62
+ base_units -= fee_base_units;
63
+ if (base_units <= 0) {
64
+ throw new Error('withdraw amount too low, at least ' + fee_base_units / token_rent_fee);
65
+ }
66
+ let isPartial = false;
67
+ let recipient_ata = getAssociatedTokenAddressSync(token.pubkey, recipient, true);
68
+ let feeRecipientTokenAccount = getAssociatedTokenAddressSync(token.pubkey, FEE_RECIPIENT, true);
69
+ let signerTokenAccount = getAssociatedTokenAddressSync(token.pubkey, publicKey);
70
+ logger.debug('Encryption key generated from user keypair');
71
+ // Get tree account (uses specific addresses for NOVA, derives for others)
72
+ const treeAccount = getTreeAccountForToken(token.pubkey);
73
+ const { globalConfigAccount, treeTokenAccount } = getProgramAccounts();
74
+ // Get current tree state
75
+ const { root, nextIndex: currentNextIndex } = await queryRemoteTreeState(token.name);
76
+ logger.debug(`Using tree root: ${root}`);
77
+ logger.debug(`New UTXOs will be inserted at indices: ${currentNextIndex} and ${currentNextIndex + 1}`);
78
+ // Generate a deterministic private key derived from the wallet keypair
79
+ const utxoPrivateKey = encryptionService.deriveUtxoPrivateKey();
80
+ // Create a UTXO keypair that will be used for all inputs and outputs
81
+ const utxoKeypair = new UtxoKeypair(utxoPrivateKey, lightWasm);
82
+ logger.debug('Using wallet-derived UTXO keypair for withdrawal');
83
+ // Generate a deterministic private key derived from the wallet keypair (V2)
84
+ const utxoPrivateKeyV2 = encryptionService.getUtxoPrivateKeyV2();
85
+ const utxoKeypairV2 = new UtxoKeypair(utxoPrivateKeyV2, lightWasm);
86
+ // Fetch existing UTXOs for this user
87
+ logger.debug('\nFetching existing UTXOs...');
88
+ const mintUtxos = await getUtxosSPL({ connection, publicKey, encryptionService, storage, mintAddress });
89
+ logger.debug(`Found ${mintUtxos.length} total UTXOs for ${token.name}`);
90
+ // Calculate and log total unspent UTXO balance
91
+ const totalUnspentBalance = mintUtxos.reduce((sum, utxo) => sum.add(utxo.amount), new BN(0));
92
+ logger.debug(`Total unspent UTXO balance before: ${totalUnspentBalance.toString()} lamports (${totalUnspentBalance.toNumber() / 1e9} SOL)`);
93
+ if (mintUtxos.length < 1) {
94
+ throw new Error('Need at least 1 unspent UTXO to perform a withdrawal');
95
+ }
96
+ // Sort UTXOs by amount in descending order to use the largest ones first
97
+ mintUtxos.sort((a, b) => b.amount.cmp(a.amount));
98
+ // Use the largest UTXO as first input, and either second largest UTXO or dummy UTXO as second input
99
+ const firstInput = mintUtxos[0];
100
+ const secondInput = mintUtxos.length > 1 ? mintUtxos[1] : new Utxo({
101
+ lightWasm,
102
+ keypair: utxoKeypair,
103
+ amount: '0',
104
+ mintAddress: token.pubkey.toString()
105
+ });
106
+ const inputs = [firstInput, secondInput];
107
+ logger.debug(`firstInput index: ${firstInput.index}, commitment: ${firstInput.getCommitment()}`);
108
+ logger.debug(`secondInput index: ${secondInput.index}, commitment: ${secondInput.getCommitment()}`);
109
+ const totalInputAmount = firstInput.amount.add(secondInput.amount);
110
+ logger.debug(`Using UTXO with amount: ${firstInput.amount.toString()} and ${secondInput.amount.gt(new BN(0)) ? 'second UTXO with amount: ' + secondInput.amount.toString() : 'dummy UTXO'}`);
111
+ if (totalInputAmount.toNumber() === 0) {
112
+ throw new Error('no balance');
113
+ }
114
+ if (totalInputAmount.lt(new BN(base_units + fee_base_units))) {
115
+ isPartial = true;
116
+ base_units = totalInputAmount.toNumber();
117
+ base_units -= fee_base_units;
118
+ }
119
+ // Calculate the change amount (what's left after withdrawal and fee)
120
+ const changeAmount = totalInputAmount.sub(new BN(base_units)).sub(new BN(fee_base_units));
121
+ logger.debug(`Withdrawing ${base_units} lamports with ${fee_base_units} fee, ${changeAmount.toString()} as change`);
122
+ // Get Merkle proofs for both input UTXOs
123
+ const inputMerkleProofs = await Promise.all(inputs.map(async (utxo, index) => {
124
+ // For dummy UTXO (amount is 0), use a zero-filled proof
125
+ if (utxo.amount.eq(new BN(0))) {
126
+ return {
127
+ pathElements: [...new Array(MERKLE_TREE_DEPTH).fill("0")],
128
+ pathIndices: Array(MERKLE_TREE_DEPTH).fill(0)
129
+ };
130
+ }
131
+ // For real UTXOs, fetch the proof from API
132
+ const commitment = await utxo.getCommitment();
133
+ return fetchMerkleProof(commitment, token.name);
134
+ }));
135
+ // Extract path elements and indices
136
+ const inputMerklePathElements = inputMerkleProofs.map(proof => proof.pathElements);
137
+ const inputMerklePathIndices = inputs.map(utxo => utxo.index || 0);
138
+ // Create outputs: first output is change, second is dummy (required by protocol)
139
+ const outputs = [
140
+ new Utxo({
141
+ lightWasm,
142
+ amount: changeAmount.toString(),
143
+ keypair: utxoKeypairV2,
144
+ index: currentNextIndex,
145
+ mintAddress: token.pubkey.toString()
146
+ }), // Change output
147
+ new Utxo({
148
+ lightWasm,
149
+ amount: '0',
150
+ keypair: utxoKeypairV2,
151
+ index: currentNextIndex + 1,
152
+ mintAddress: token.pubkey.toString()
153
+ }) // Empty UTXO
154
+ ];
155
+ // For withdrawals, extAmount is negative (funds leaving the system)
156
+ const extAmount = -base_units;
157
+ const publicAmountForCircuit = new BN(extAmount).sub(new BN(fee_base_units)).add(FIELD_SIZE).mod(FIELD_SIZE);
158
+ logger.debug(`Public amount calculation: (${extAmount} - ${fee_base_units} + FIELD_SIZE) % FIELD_SIZE = ${publicAmountForCircuit.toString()}`);
159
+ // Verify this matches the circuit balance equation: sumIns + publicAmount = sumOuts
160
+ const sumIns = inputs.reduce((sum, input) => sum.add(input.amount), new BN(0));
161
+ const sumOuts = outputs.reduce((sum, output) => sum.add(output.amount), new BN(0));
162
+ logger.debug(`Circuit balance check: sumIns(${sumIns.toString()}) + publicAmount(${publicAmountForCircuit.toString()}) should equal sumOuts(${sumOuts.toString()})`);
163
+ // Convert to circuit-compatible format
164
+ const publicAmountCircuitResult = sumIns.add(publicAmountForCircuit).mod(FIELD_SIZE);
165
+ logger.debug(`Balance verification: ${sumIns.toString()} + ${publicAmountForCircuit.toString()} (mod FIELD_SIZE) = ${publicAmountCircuitResult.toString()}`);
166
+ logger.debug(`Expected sum of outputs: ${sumOuts.toString()}`);
167
+ logger.debug(`Balance equation satisfied: ${publicAmountCircuitResult.eq(sumOuts)}`);
168
+ // Generate nullifiers and commitments
169
+ const inputNullifiers = await Promise.all(inputs.map(x => x.getNullifier()));
170
+ const outputCommitments = await Promise.all(outputs.map(x => x.getCommitment()));
171
+ // Save original commitment and nullifier values for verification
172
+ logger.debug('\n=== UTXO VALIDATION ===');
173
+ logger.debug('Output 0 Commitment:', outputCommitments[0]);
174
+ logger.debug('Output 1 Commitment:', outputCommitments[1]);
175
+ // Encrypt the UTXO data using a compact format that includes the keypair
176
+ logger.debug('\nEncrypting UTXOs with keypair data...');
177
+ const encryptedOutput1 = encryptionService.encryptUtxo(outputs[0]);
178
+ const encryptedOutput2 = encryptionService.encryptUtxo(outputs[1]);
179
+ logger.debug(`\nOutput[0] (change):`);
180
+ await outputs[0].log();
181
+ logger.debug(`\nOutput[1] (empty):`);
182
+ await outputs[1].log();
183
+ logger.debug(`Encrypted output 1: ${encryptedOutput1.toString('hex')}`);
184
+ logger.debug(`Encrypted output 2: ${encryptedOutput2.toString('hex')}`);
185
+ logger.debug(`\nEncrypted output 1 size: ${encryptedOutput1.length} bytes`);
186
+ logger.debug(`Encrypted output 2 size: ${encryptedOutput2.length} bytes`);
187
+ logger.debug(`Total encrypted outputs size: ${encryptedOutput1.length + encryptedOutput2.length} bytes`);
188
+ // Test decryption to verify commitment values match
189
+ logger.debug('\n=== TESTING DECRYPTION ===');
190
+ logger.debug('Decrypting output 1 to verify commitment matches...');
191
+ const decryptedUtxo1 = await encryptionService.decryptUtxo(encryptedOutput1, lightWasm);
192
+ const decryptedCommitment1 = await decryptedUtxo1.getCommitment();
193
+ logger.debug('Original commitment:', outputCommitments[0]);
194
+ logger.debug('Decrypted commitment:', decryptedCommitment1);
195
+ logger.debug('Commitment matches:', outputCommitments[0] === decryptedCommitment1);
196
+ // Create the withdrawal ExtData with real encrypted outputs
197
+ const extData = {
198
+ // it can be any address
199
+ recipient: recipient_ata,
200
+ extAmount: new BN(extAmount),
201
+ encryptedOutput1: encryptedOutput1,
202
+ encryptedOutput2: encryptedOutput2,
203
+ fee: new BN(fee_base_units),
204
+ feeRecipient: feeRecipientTokenAccount,
205
+ mintAddress: token.pubkey.toString()
206
+ };
207
+ // Calculate the extDataHash with the encrypted outputs
208
+ const calculatedExtDataHash = getExtDataHash(extData);
209
+ // Create the input for the proof generation
210
+ const input = {
211
+ // Common transaction data
212
+ root: root,
213
+ mintAddress: getMintAddressField(token.pubkey), // new mint address
214
+ publicAmount: publicAmountForCircuit.toString(), // Use proper field arithmetic result
215
+ extDataHash: calculatedExtDataHash,
216
+ // Input UTXO data (UTXOs being spent) - ensure all values are in decimal format
217
+ inAmount: inputs.map(x => x.amount.toString(10)),
218
+ inPrivateKey: inputs.map(x => x.keypair.privkey),
219
+ inBlinding: inputs.map(x => x.blinding.toString(10)),
220
+ inPathIndices: inputMerklePathIndices,
221
+ inPathElements: inputMerklePathElements,
222
+ inputNullifier: inputNullifiers, // Use resolved values instead of Promise objects
223
+ // Output UTXO data (UTXOs being created) - ensure all values are in decimal format
224
+ outAmount: outputs.map(x => x.amount.toString(10)),
225
+ outBlinding: outputs.map(x => x.blinding.toString(10)),
226
+ outPubkey: outputs.map(x => x.keypair.pubkey),
227
+ outputCommitment: outputCommitments,
228
+ };
229
+ logger.info('generating ZK proof...');
230
+ // Generate the zero-knowledge proof
231
+ const { proof, publicSignals } = await prove(input, keyBasePath);
232
+ // Parse the proof and public signals into byte arrays
233
+ const proofInBytes = parseProofToBytesArray(proof);
234
+ const inputsInBytes = parseToBytesArray(publicSignals);
235
+ // Create the proof object to submit to the program
236
+ const proofToSubmit = {
237
+ proofA: proofInBytes.proofA,
238
+ proofB: proofInBytes.proofB.flat(),
239
+ proofC: proofInBytes.proofC,
240
+ root: inputsInBytes[0],
241
+ publicAmount: inputsInBytes[1],
242
+ extDataHash: inputsInBytes[2],
243
+ inputNullifiers: [
244
+ inputsInBytes[3],
245
+ inputsInBytes[4]
246
+ ],
247
+ outputCommitments: [
248
+ inputsInBytes[5],
249
+ inputsInBytes[6]
250
+ ],
251
+ };
252
+ // Find PDAs for nullifiers and commitments
253
+ const { nullifier0PDA, nullifier1PDA } = findNullifierPDAs(proofToSubmit);
254
+ const { nullifier2PDA, nullifier3PDA } = findCrossCheckNullifierPDAs(proofToSubmit);
255
+ // Serialize the proof and extData
256
+ const serializedProof = serializeProofAndExtData(proofToSubmit, extData, true);
257
+ logger.debug(`Total instruction data size: ${serializedProof.length} bytes`);
258
+ const [globalConfigPda, globalConfigPdaBump] = await PublicKey.findProgramAddressSync([Buffer.from("global_config")], PROGRAM_ID);
259
+ const treeAta = getAssociatedTokenAddressSync(token.pubkey, globalConfigPda, true);
260
+ // Prepare withdraw parameters for indexer backend
261
+ const withdrawParams = {
262
+ serializedProof: serializedProof.toString('base64'),
263
+ treeAccount: treeAccount.toString(),
264
+ nullifier0PDA: nullifier0PDA.toString(),
265
+ nullifier1PDA: nullifier1PDA.toString(),
266
+ nullifier2PDA: nullifier2PDA.toString(),
267
+ nullifier3PDA: nullifier3PDA.toString(),
268
+ treeTokenAccount: treeTokenAccount.toString(),
269
+ globalConfigAccount: globalConfigAccount.toString(),
270
+ recipient: recipient.toString(),
271
+ feeRecipientAccount: FEE_RECIPIENT.toString(),
272
+ extAmount: extAmount,
273
+ fee: fee_base_units,
274
+ lookupTableAddress: ALT_ADDRESS.toString(),
275
+ senderAddress: publicKey.toString(),
276
+ treeAta: treeAta.toString(),
277
+ recipientAta: recipient_ata.toString(),
278
+ mintAddress: token.pubkey.toString(),
279
+ feeRecipientTokenAccount: feeRecipientTokenAccount.toString()
280
+ };
281
+ logger.debug('Prepared withdraw parameters for indexer backend');
282
+ // Submit to indexer backend instead of directly to Solana
283
+ logger.info('submitting transaction to relayer...');
284
+ const signature = await submitWithdrawToIndexer(withdrawParams);
285
+ // Wait a moment for the transaction to be confirmed
286
+ logger.info('waiting for transaction confirmation...');
287
+ let retryTimes = 0;
288
+ let itv = 2;
289
+ const encryptedOutputStr = Buffer.from(encryptedOutput1).toString('hex');
290
+ let start = Date.now();
291
+ while (true) {
292
+ logger.info(`retryTimes: ${retryTimes}`);
293
+ await new Promise(resolve => setTimeout(resolve, itv * 1000));
294
+ logger.info('Fetching updated tree state...');
295
+ let res = await fetch(RELAYER_API_URL + '/utxos/check/' + encryptedOutputStr + '?token=' + token.name);
296
+ let resJson = await res.json();
297
+ logger.debug('resJson:', resJson);
298
+ if (resJson.exists) {
299
+ return { isPartial, tx: signature, recipient: recipient.toString(), base_units, fee_base_units };
300
+ }
301
+ if (retryTimes >= 10) {
302
+ throw new Error('Refresh the page to see latest balance.');
303
+ }
304
+ retryTimes++;
305
+ }
306
+ }