@veil-cash/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Transaction preparation for Veil SDK
3
+ * Core function to build ZK proofs for withdrawals and transfers
4
+ */
5
+
6
+ import { buildMerkleTree, MERKLE_TREE_HEIGHT } from './merkle.js';
7
+ import { prove, selectCircuit, type ProofInput } from './prover.js';
8
+ import { toFixedHex, getExtDataHash, shuffle, FIELD_SIZE } from './utils.js';
9
+ import { Utxo } from './utxo.js';
10
+
11
+ /**
12
+ * External data for a transaction (sent alongside proof)
13
+ */
14
+ export interface ExtData {
15
+ recipient: string;
16
+ extAmount: string;
17
+ relayer: string;
18
+ fee: string;
19
+ encryptedOutput1: string;
20
+ encryptedOutput2: string;
21
+ }
22
+
23
+ /**
24
+ * Proof arguments for on-chain verification
25
+ */
26
+ export interface ProofArgs {
27
+ proof: string;
28
+ root: string;
29
+ inputNullifiers: string[];
30
+ outputCommitments: string[];
31
+ publicAmount: string;
32
+ extDataHash: string;
33
+ }
34
+
35
+ /**
36
+ * Result of preparing a transaction
37
+ */
38
+ export interface TransactionResult {
39
+ args: ProofArgs;
40
+ extData: ExtData;
41
+ }
42
+
43
+ /**
44
+ * Parameters for preparing a transaction
45
+ */
46
+ export interface PrepareTransactionParams {
47
+ /** All commitments from the merkle tree (from pool contract) */
48
+ commitments: (string | bigint)[];
49
+ /** Input UTXOs to spend */
50
+ inputs?: Utxo[];
51
+ /** Output UTXOs to create */
52
+ outputs?: Utxo[];
53
+ /** Transaction fee (usually 0 for relay) */
54
+ fee?: bigint | number;
55
+ /** Recipient address for withdrawals (0x0 for transfers) */
56
+ recipient?: string | bigint | number;
57
+ /** Relayer address (0x0 for now) */
58
+ relayer?: string | bigint | number;
59
+ /** Optional progress callback */
60
+ onProgress?: (stage: string, detail?: string) => void;
61
+ }
62
+
63
+ /**
64
+ * Internal function to generate proof
65
+ */
66
+ interface GetProofParams {
67
+ inputs: Utxo[];
68
+ outputs: Utxo[];
69
+ tree: ReturnType<typeof buildMerkleTree> extends Promise<infer T> ? T : never;
70
+ extAmount: bigint;
71
+ fee: bigint;
72
+ recipient: string | bigint;
73
+ relayer: string | bigint;
74
+ onProgress?: (stage: string, detail?: string) => void;
75
+ }
76
+
77
+ async function getProof({
78
+ inputs,
79
+ outputs,
80
+ tree,
81
+ extAmount,
82
+ fee,
83
+ recipient,
84
+ relayer,
85
+ onProgress,
86
+ }: GetProofParams): Promise<TransactionResult> {
87
+ // Shuffle inputs and outputs for privacy
88
+ inputs = shuffle([...inputs]);
89
+ outputs = shuffle([...outputs]);
90
+
91
+ onProgress?.('Building merkle paths...');
92
+
93
+ const inputMerklePathIndices: number[] = [];
94
+ const inputMerklePathElements: (bigint | number)[][] = [];
95
+
96
+ // Get merkle path for each input
97
+ for (const input of inputs) {
98
+ if (input.amount > 0n) {
99
+ const inputIndex = tree.indexOf(toFixedHex(input.getCommitment()));
100
+ if (inputIndex < 0) {
101
+ throw new Error(`Input commitment ${toFixedHex(input.getCommitment())} was not found in merkle tree`);
102
+ }
103
+ input.index = inputIndex;
104
+ inputMerklePathIndices.push(inputIndex);
105
+ inputMerklePathElements.push(
106
+ tree.path(inputIndex).pathElements.map((el: string | number | bigint) => BigInt(el))
107
+ );
108
+ } else {
109
+ // Empty input (padding) - use zero path
110
+ inputMerklePathIndices.push(0);
111
+ inputMerklePathElements.push(new Array(tree.levels).fill(0));
112
+ }
113
+ }
114
+
115
+ onProgress?.('Encrypting outputs...');
116
+
117
+ // Build external data
118
+ const extData: ExtData = {
119
+ recipient: toFixedHex(recipient, 20),
120
+ extAmount: extAmount.toString(),
121
+ relayer: toFixedHex(relayer, 20),
122
+ fee: fee.toString(),
123
+ encryptedOutput1: outputs[0].encrypt(),
124
+ encryptedOutput2: outputs[1].encrypt(),
125
+ };
126
+
127
+ // Calculate extDataHash using raw values
128
+ const extDataHashInput = {
129
+ recipient: recipient,
130
+ extAmount: extAmount,
131
+ relayer: relayer,
132
+ fee: fee,
133
+ encryptedOutput1: extData.encryptedOutput1,
134
+ encryptedOutput2: extData.encryptedOutput2,
135
+ };
136
+ const extDataHash = getExtDataHash(extDataHashInput);
137
+
138
+ // Build proof input
139
+ const proofInput: ProofInput = {
140
+ root: BigInt(tree.root()),
141
+ inputNullifier: inputs.map((x) => x.getNullifier()),
142
+ outputCommitment: outputs.map((x) => x.getCommitment()),
143
+ publicAmount: ((BigInt(extAmount) - BigInt(fee) + FIELD_SIZE) % FIELD_SIZE).toString(),
144
+ extDataHash,
145
+
146
+ // Input UTXO data
147
+ inAmount: inputs.map((x) => x.amount),
148
+ inPrivateKey: inputs.map((x) => x.keypair.privkey),
149
+ inBlinding: inputs.map((x) => x.blinding),
150
+ inPathIndices: inputMerklePathIndices,
151
+ inPathElements: inputMerklePathElements,
152
+
153
+ // Output UTXO data
154
+ outAmount: outputs.map((x) => x.amount),
155
+ outBlinding: outputs.map((x) => x.blinding),
156
+ outPubkey: outputs.map((x) => x.keypair.pubkey),
157
+ };
158
+
159
+ onProgress?.('Generating ZK proof...', `${inputs.length} inputs`);
160
+
161
+ // Select circuit based on input count and generate proof
162
+ const circuitName = selectCircuit(inputs.length);
163
+ const proof = await prove(proofInput, circuitName);
164
+
165
+ // Build proof arguments for on-chain verification
166
+ const args: ProofArgs = {
167
+ proof,
168
+ root: toFixedHex(proofInput.root),
169
+ inputNullifiers: inputs.map((x) => toFixedHex(x.getNullifier())),
170
+ outputCommitments: outputs.map((x) => toFixedHex(x.getCommitment())),
171
+ publicAmount: toFixedHex(proofInput.publicAmount),
172
+ extDataHash: toFixedHex(extDataHash),
173
+ };
174
+
175
+ onProgress?.('Proof generated successfully');
176
+
177
+ return {
178
+ args,
179
+ extData,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Prepare a transaction (withdrawal or transfer)
185
+ * Builds the ZK proof and external data needed for on-chain execution
186
+ *
187
+ * @param params - Transaction parameters
188
+ * @returns Proof arguments and external data
189
+ *
190
+ * @example
191
+ * ```typescript
192
+ * // Withdrawal: send funds to external address
193
+ * const result = await prepareTransaction({
194
+ * commitments: poolCommitments,
195
+ * inputs: [utxo1, utxo2],
196
+ * outputs: [changeUtxo], // Just change
197
+ * recipient: '0x1234...', // Withdrawal address
198
+ * fee: 0,
199
+ * relayer: '0x0000...',
200
+ * });
201
+ *
202
+ * // Transfer: move funds to another Veil user
203
+ * const result = await prepareTransaction({
204
+ * commitments: poolCommitments,
205
+ * inputs: [utxo1],
206
+ * outputs: [recipientUtxo, changeUtxo],
207
+ * recipient: 0, // No external recipient
208
+ * fee: 0,
209
+ * relayer: '0x0000...',
210
+ * });
211
+ * ```
212
+ */
213
+ export async function prepareTransaction({
214
+ commitments,
215
+ inputs = [],
216
+ outputs = [],
217
+ fee = 0,
218
+ recipient = 0,
219
+ relayer = 0,
220
+ onProgress,
221
+ }: PrepareTransactionParams): Promise<TransactionResult> {
222
+ // Validate input/output counts
223
+ if (inputs.length > 16 || outputs.length > 2) {
224
+ throw new Error('Incorrect inputs/outputs count. Maximum: 16 inputs, 2 outputs.');
225
+ }
226
+
227
+ // Pad inputs to 2 or 16 with empty UTXOs
228
+ while (inputs.length !== 2 && inputs.length < 16) {
229
+ inputs.push(new Utxo());
230
+ }
231
+
232
+ // Pad outputs to 2 with empty UTXOs
233
+ while (outputs.length < 2) {
234
+ outputs.push(new Utxo());
235
+ }
236
+
237
+ // Calculate external amount (fee + outputs - inputs)
238
+ // Positive = withdrawal, Negative = deposit
239
+ const extAmount = BigInt(fee) +
240
+ outputs.reduce((sum, x) => sum + x.amount, 0n) -
241
+ inputs.reduce((sum, x) => sum + x.amount, 0n);
242
+
243
+ onProgress?.('Building merkle tree...');
244
+
245
+ // Build merkle tree and generate proof
246
+ const tree = await buildMerkleTree(commitments);
247
+
248
+ const result = await getProof({
249
+ inputs,
250
+ outputs,
251
+ tree,
252
+ extAmount,
253
+ fee: BigInt(fee),
254
+ recipient: String(recipient),
255
+ relayer: String(relayer),
256
+ onProgress,
257
+ });
258
+
259
+ return result;
260
+ }
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Transfer functions for Veil SDK
3
+ * Build ZK proofs to transfer funds privately within the pool
4
+ */
5
+
6
+ import { createPublicClient, http, parseUnits } from 'viem';
7
+ import { base } from 'viem/chains';
8
+ import { getAddresses, POOL_CONFIG } from './addresses.js';
9
+ import { POOL_ABI, ENTRY_ABI } from './abi.js';
10
+ import { Keypair } from './keypair.js';
11
+ import { Utxo } from './utxo.js';
12
+ import { getPrivateBalance } from './balance.js';
13
+ import { prepareTransaction } from './transaction.js';
14
+ import { submitRelay } from './relay.js';
15
+ import { selectUtxosForWithdraw } from './withdraw.js';
16
+ import type {
17
+ BuildTransferProofOptions,
18
+ ProofBuildResult,
19
+ TransferResult,
20
+ } from './types.js';
21
+
22
+ /**
23
+ * Check if a recipient is registered and get their deposit key
24
+ *
25
+ * @param address - Address to check
26
+ * @param rpcUrl - Optional RPC URL
27
+ * @returns Whether registered and their deposit key if so
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * const { isRegistered, depositKey } = await checkRecipientRegistration('0x1234...');
32
+ * if (!isRegistered) {
33
+ * console.log('Recipient needs to register first');
34
+ * }
35
+ * ```
36
+ */
37
+ export async function checkRecipientRegistration(
38
+ address: `0x${string}`,
39
+ rpcUrl?: string
40
+ ): Promise<{ isRegistered: boolean; depositKey?: string }> {
41
+ const addresses = getAddresses();
42
+
43
+ const publicClient = createPublicClient({
44
+ chain: base,
45
+ transport: http(rpcUrl),
46
+ });
47
+
48
+ const depositKey = await publicClient.readContract({
49
+ address: addresses.entry,
50
+ abi: ENTRY_ABI,
51
+ functionName: 'depositKeys',
52
+ args: [address],
53
+ }) as string;
54
+
55
+ // If depositKey exists and is not empty, recipient is registered
56
+ const isRegistered = !!(depositKey && depositKey !== '0x' && depositKey.length > 2);
57
+
58
+ return {
59
+ isRegistered,
60
+ depositKey: isRegistered ? depositKey : undefined,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Fetch all commitments from the pool contract
66
+ */
67
+ async function fetchCommitments(
68
+ rpcUrl: string | undefined,
69
+ poolAddress: `0x${string}`,
70
+ onProgress?: (stage: string, detail?: string) => void
71
+ ): Promise<string[]> {
72
+ const publicClient = createPublicClient({
73
+ chain: base,
74
+ transport: http(rpcUrl),
75
+ });
76
+
77
+ onProgress?.('Fetching commitment count...');
78
+ const nextIndex = await publicClient.readContract({
79
+ address: poolAddress,
80
+ abi: POOL_ABI,
81
+ functionName: 'nextIndex',
82
+ }) as number;
83
+
84
+ if (nextIndex === 0) {
85
+ return [];
86
+ }
87
+
88
+ const BATCH_SIZE = 5000;
89
+ const commitments: string[] = [];
90
+ const totalBatches = Math.ceil(nextIndex / BATCH_SIZE);
91
+
92
+ for (let start = 0; start < nextIndex; start += BATCH_SIZE) {
93
+ const end = Math.min(start + BATCH_SIZE, nextIndex);
94
+ const batchNum = Math.floor(start / BATCH_SIZE) + 1;
95
+ onProgress?.('Fetching commitments', `batch ${batchNum}/${totalBatches}`);
96
+
97
+ const batch = await publicClient.readContract({
98
+ address: poolAddress,
99
+ abi: POOL_ABI,
100
+ functionName: 'getCommitments',
101
+ args: [BigInt(start), BigInt(end)],
102
+ }) as `0x${string}`[];
103
+
104
+ commitments.push(...batch.map(c => c.toString()));
105
+ }
106
+
107
+ return commitments;
108
+ }
109
+
110
+ /**
111
+ * Build a transfer proof
112
+ *
113
+ * This function:
114
+ * 1. Verifies the recipient is registered
115
+ * 2. Fetches the sender's unspent UTXOs
116
+ * 3. Selects UTXOs to cover the transfer amount
117
+ * 4. Creates output UTXOs for recipient and change
118
+ * 5. Builds the ZK proof
119
+ *
120
+ * @param options - Transfer options
121
+ * @returns Proof data ready for relay submission
122
+ *
123
+ * @example
124
+ * ```typescript
125
+ * const senderKeypair = new Keypair(process.env.VEIL_KEY);
126
+ * const proof = await buildTransferProof({
127
+ * amount: '0.1',
128
+ * recipientAddress: '0x1234...',
129
+ * senderKeypair,
130
+ * });
131
+ * ```
132
+ */
133
+ export async function buildTransferProof(
134
+ options: BuildTransferProofOptions
135
+ ): Promise<ProofBuildResult> {
136
+ const {
137
+ amount,
138
+ recipientAddress,
139
+ senderKeypair,
140
+ rpcUrl,
141
+ onProgress,
142
+ } = options;
143
+
144
+ const addresses = getAddresses();
145
+ const poolConfig = POOL_CONFIG.eth;
146
+ const poolAddress = addresses.ethPool;
147
+
148
+ // 1. Check recipient is registered and get their deposit key
149
+ onProgress?.('Checking recipient registration...');
150
+ const { isRegistered, depositKey } = await checkRecipientRegistration(
151
+ recipientAddress,
152
+ rpcUrl
153
+ );
154
+
155
+ if (!isRegistered || !depositKey) {
156
+ throw new Error(`Recipient ${recipientAddress} is not registered. They need to register first.`);
157
+ }
158
+
159
+ // 2. Get sender's unspent UTXOs
160
+ onProgress?.('Fetching your UTXOs...');
161
+ const balanceResult = await getPrivateBalance({
162
+ keypair: senderKeypair,
163
+ rpcUrl,
164
+ onProgress,
165
+ });
166
+
167
+ const unspentUtxoInfos = balanceResult.utxos.filter(u => !u.isSpent);
168
+ if (unspentUtxoInfos.length === 0) {
169
+ throw new Error('No unspent UTXOs available for transfer');
170
+ }
171
+
172
+ // Re-decrypt UTXOs to get full Utxo objects
173
+ onProgress?.('Preparing UTXOs...');
174
+
175
+ const publicClient = createPublicClient({
176
+ chain: base,
177
+ transport: http(rpcUrl),
178
+ });
179
+
180
+ const utxos: Utxo[] = [];
181
+ for (const utxoInfo of unspentUtxoInfos) {
182
+ const encryptedOutputs = await publicClient.readContract({
183
+ address: poolAddress,
184
+ abi: POOL_ABI,
185
+ functionName: 'getEncryptedOutputs',
186
+ args: [BigInt(utxoInfo.index), BigInt(utxoInfo.index + 1)],
187
+ }) as string[];
188
+
189
+ if (encryptedOutputs.length > 0) {
190
+ try {
191
+ const utxo = Utxo.decrypt(encryptedOutputs[0], senderKeypair);
192
+ utxo.index = utxoInfo.index;
193
+ utxos.push(utxo);
194
+ } catch {
195
+ // Skip if decryption fails
196
+ }
197
+ }
198
+ }
199
+
200
+ if (utxos.length === 0) {
201
+ throw new Error('Failed to decrypt UTXOs');
202
+ }
203
+
204
+ // 3. Select UTXOs for transfer
205
+ onProgress?.('Selecting UTXOs...');
206
+ const { selectedUtxos, changeAmount } = selectUtxosForWithdraw(
207
+ utxos,
208
+ amount,
209
+ poolConfig.decimals
210
+ );
211
+
212
+ // 4. Create output UTXOs
213
+ const outputs: Utxo[] = [];
214
+ const transferWei = parseUnits(amount, poolConfig.decimals);
215
+
216
+ // Create recipient UTXO using their deposit key
217
+ const recipientKeypair = Keypair.fromString(depositKey);
218
+ const recipientUtxo = new Utxo({
219
+ amount: transferWei,
220
+ keypair: recipientKeypair,
221
+ });
222
+ outputs.push(recipientUtxo);
223
+
224
+ // Create change UTXO for sender if needed
225
+ if (changeAmount > 0n) {
226
+ const changeUtxo = new Utxo({
227
+ amount: changeAmount,
228
+ keypair: senderKeypair,
229
+ });
230
+ outputs.push(changeUtxo);
231
+ }
232
+
233
+ // 5. Fetch all commitments from pool
234
+ const commitments = await fetchCommitments(rpcUrl, poolAddress, onProgress);
235
+
236
+ // 6. Build the ZK proof
237
+ // For transfers, recipient field is 0x0 (no external withdrawal)
238
+ onProgress?.('Building ZK proof...');
239
+ const result = await prepareTransaction({
240
+ commitments,
241
+ inputs: selectedUtxos,
242
+ outputs,
243
+ fee: 0,
244
+ recipient: '0x0000000000000000000000000000000000000000',
245
+ relayer: '0x0000000000000000000000000000000000000000',
246
+ onProgress,
247
+ });
248
+
249
+ return {
250
+ proofArgs: {
251
+ proof: result.args.proof,
252
+ root: result.args.root,
253
+ inputNullifiers: result.args.inputNullifiers,
254
+ outputCommitments: result.args.outputCommitments as [string, string],
255
+ publicAmount: result.args.publicAmount,
256
+ extDataHash: result.args.extDataHash,
257
+ },
258
+ extData: result.extData,
259
+ inputCount: selectedUtxos.length,
260
+ outputCount: outputs.length,
261
+ amount,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Execute a transfer by building proof and submitting to relay
267
+ *
268
+ * @param options - Transfer options
269
+ * @returns Transfer result with transaction hash
270
+ *
271
+ * @example
272
+ * ```typescript
273
+ * const senderKeypair = new Keypair(process.env.VEIL_KEY);
274
+ * const result = await transfer({
275
+ * amount: '0.1',
276
+ * recipientAddress: '0x1234...',
277
+ * senderKeypair,
278
+ * });
279
+ *
280
+ * console.log(`Transfer tx: ${result.transactionHash}`);
281
+ * ```
282
+ */
283
+ export async function transfer(
284
+ options: BuildTransferProofOptions
285
+ ): Promise<TransferResult> {
286
+ const { amount, recipientAddress, onProgress } = options;
287
+
288
+ // Build the proof
289
+ const proof = await buildTransferProof(options);
290
+
291
+ // Submit to relay
292
+ onProgress?.('Submitting to relay...');
293
+ const relayResult = await submitRelay({
294
+ type: 'transfer',
295
+ pool: 'eth',
296
+ proofArgs: proof.proofArgs,
297
+ extData: proof.extData,
298
+ metadata: {
299
+ amount,
300
+ recipient: recipientAddress,
301
+ inputUtxoCount: proof.inputCount,
302
+ outputUtxoCount: proof.outputCount,
303
+ },
304
+ });
305
+
306
+ return {
307
+ success: relayResult.success,
308
+ transactionHash: relayResult.transactionHash,
309
+ blockNumber: relayResult.blockNumber,
310
+ amount,
311
+ recipient: recipientAddress,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Merge UTXOs by doing a self-transfer
317
+ * This consolidates multiple small UTXOs into fewer larger ones
318
+ *
319
+ * Unlike regular transfers, merge doesn't need a recipient address -
320
+ * it uses the sender's keypair directly to create the output UTXO.
321
+ *
322
+ * @param options - Merge options
323
+ * @returns Transfer result
324
+ *
325
+ * @example
326
+ * ```typescript
327
+ * const keypair = new Keypair(process.env.VEIL_KEY);
328
+ * const result = await mergeUtxos({
329
+ * amount: '0.5', // Total amount to consolidate
330
+ * keypair,
331
+ * });
332
+ * ```
333
+ */
334
+ export async function mergeUtxos(options: {
335
+ amount: string;
336
+ keypair: Keypair;
337
+ rpcUrl?: string;
338
+ onProgress?: (stage: string, detail?: string) => void;
339
+ }): Promise<TransferResult> {
340
+ const { amount, keypair, rpcUrl, onProgress } = options;
341
+
342
+ const addresses = getAddresses();
343
+ const poolConfig = POOL_CONFIG.eth;
344
+ const poolAddress = addresses.ethPool;
345
+
346
+ // 1. Get sender's unspent UTXOs
347
+ onProgress?.('Fetching your UTXOs...');
348
+ const balanceResult = await getPrivateBalance({
349
+ keypair,
350
+ rpcUrl,
351
+ onProgress,
352
+ });
353
+
354
+ const unspentUtxoInfos = balanceResult.utxos.filter(u => !u.isSpent);
355
+ if (unspentUtxoInfos.length === 0) {
356
+ throw new Error('No unspent UTXOs available for merge');
357
+ }
358
+
359
+ // Re-decrypt UTXOs to get full Utxo objects
360
+ onProgress?.('Preparing UTXOs...');
361
+
362
+ const publicClient = createPublicClient({
363
+ chain: base,
364
+ transport: http(rpcUrl),
365
+ });
366
+
367
+ const utxos: Utxo[] = [];
368
+ for (const utxoInfo of unspentUtxoInfos) {
369
+ const encryptedOutputs = await publicClient.readContract({
370
+ address: poolAddress,
371
+ abi: POOL_ABI,
372
+ functionName: 'getEncryptedOutputs',
373
+ args: [BigInt(utxoInfo.index), BigInt(utxoInfo.index + 1)],
374
+ }) as string[];
375
+
376
+ if (encryptedOutputs.length > 0) {
377
+ try {
378
+ const utxo = Utxo.decrypt(encryptedOutputs[0], keypair);
379
+ utxo.index = utxoInfo.index;
380
+ utxos.push(utxo);
381
+ } catch {
382
+ // Skip if decryption fails
383
+ }
384
+ }
385
+ }
386
+
387
+ if (utxos.length === 0) {
388
+ throw new Error('Failed to decrypt UTXOs');
389
+ }
390
+
391
+ // 2. Select UTXOs for merge
392
+ onProgress?.('Selecting UTXOs...');
393
+ const { selectedUtxos, changeAmount } = selectUtxosForWithdraw(
394
+ utxos,
395
+ amount,
396
+ poolConfig.decimals
397
+ );
398
+
399
+ // 3. Create output UTXO - use sender's keypair directly (self-transfer)
400
+ const outputs: Utxo[] = [];
401
+ const mergeWei = parseUnits(amount, poolConfig.decimals);
402
+
403
+ // Create merged UTXO using own keypair
404
+ const mergedUtxo = new Utxo({
405
+ amount: mergeWei,
406
+ keypair: keypair,
407
+ });
408
+ outputs.push(mergedUtxo);
409
+
410
+ // Create change UTXO if needed
411
+ if (changeAmount > 0n) {
412
+ const changeUtxo = new Utxo({
413
+ amount: changeAmount,
414
+ keypair: keypair,
415
+ });
416
+ outputs.push(changeUtxo);
417
+ }
418
+
419
+ // 4. Fetch all commitments from pool
420
+ const commitments = await fetchCommitments(rpcUrl, poolAddress, onProgress);
421
+
422
+ // 5. Build the ZK proof (recipient = 0x0 for self-transfer)
423
+ onProgress?.('Building ZK proof...');
424
+ const result = await prepareTransaction({
425
+ commitments,
426
+ inputs: selectedUtxos,
427
+ outputs,
428
+ fee: 0,
429
+ recipient: '0x0000000000000000000000000000000000000000',
430
+ relayer: '0x0000000000000000000000000000000000000000',
431
+ onProgress,
432
+ });
433
+
434
+ // 6. Submit to relay
435
+ onProgress?.('Submitting to relay...');
436
+ const relayResult = await submitRelay({
437
+ type: 'transfer',
438
+ pool: 'eth',
439
+ proofArgs: {
440
+ proof: result.args.proof,
441
+ root: result.args.root,
442
+ inputNullifiers: result.args.inputNullifiers,
443
+ outputCommitments: result.args.outputCommitments as [string, string],
444
+ publicAmount: result.args.publicAmount,
445
+ extDataHash: result.args.extDataHash,
446
+ },
447
+ extData: result.extData,
448
+ metadata: {
449
+ amount,
450
+ inputUtxoCount: selectedUtxos.length,
451
+ outputUtxoCount: outputs.length,
452
+ },
453
+ });
454
+
455
+ return {
456
+ success: relayResult.success,
457
+ transactionHash: relayResult.transactionHash,
458
+ blockNumber: relayResult.blockNumber,
459
+ amount,
460
+ recipient: 'self',
461
+ };
462
+ }