@hula-privacy/mixer 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,464 @@
1
+ /**
2
+ * Transaction building for Hula Privacy Protocol
3
+ *
4
+ * Builds privacy transactions with ZK proofs
5
+ */
6
+
7
+ import { PublicKey, SystemProgram, type TransactionInstruction } from "@solana/web3.js";
8
+ import {
9
+ getAssociatedTokenAddressSync,
10
+ createAssociatedTokenAccountIdempotentInstruction,
11
+ } from "@solana/spl-token";
12
+ import { BN } from "@coral-xyz/anchor";
13
+
14
+ import {
15
+ PROGRAM_ID,
16
+ TOKEN_2022_PROGRAM_ID,
17
+ NUM_INPUT_UTXOS,
18
+ NUM_OUTPUT_UTXOS,
19
+ MERKLE_TREE_DEPTH,
20
+ getPoolPDA,
21
+ getMerkleTreePDA,
22
+ getVaultPDA,
23
+ getNullifierPDA,
24
+ } from "./constants";
25
+ import {
26
+ bigIntToBytes32,
27
+ pubkeyToBigInt,
28
+ encryptNote,
29
+ serializeEncryptedNote,
30
+ } from "./crypto";
31
+ import { computeMerklePathFromLeaves, fetchMerkleRoot, getCurrentTreeIndex, getNextLeafIndex } from "./merkle";
32
+ import { computeCommitment, computeNullifier, createUTXO, createDummyUTXO } from "./utxo";
33
+ import { generateProof } from "./proof";
34
+ import { getRelayerClient } from "./api";
35
+ import type {
36
+ UTXO,
37
+ WalletKeys,
38
+ TransactionRequest,
39
+ BuiltTransaction,
40
+ CircuitInputs,
41
+ OutputSpec,
42
+ } from "./types";
43
+
44
+ // ============================================================================
45
+ // Transaction Builder
46
+ // ============================================================================
47
+
48
+ /**
49
+ * Build a privacy transaction
50
+ *
51
+ * This handles all transaction types: deposits, transfers, and withdrawals.
52
+ */
53
+ export async function buildTransaction(
54
+ request: TransactionRequest,
55
+ walletKeys: WalletKeys,
56
+ relayerUrl?: string
57
+ ): Promise<BuiltTransaction> {
58
+ const client = getRelayerClient(relayerUrl);
59
+
60
+ // Get current pool state
61
+ const pool = await client.getPool();
62
+ const currentTreeIndex = pool.currentTreeIndex;
63
+
64
+ // Validate inputs
65
+ const depositAmount = request.depositAmount ?? 0n;
66
+ const withdrawAmount = request.withdrawAmount ?? 0n;
67
+ const fee = request.fee ?? 0n;
68
+ const inputUtxos = request.inputUtxos ?? [];
69
+ const outputs = request.outputs ?? [];
70
+
71
+ if (depositAmount === 0n && inputUtxos.length === 0) {
72
+ throw new Error("Either depositAmount or inputUtxos is required");
73
+ }
74
+
75
+ if (withdrawAmount > 0n && !request.recipient) {
76
+ throw new Error("recipient is required when withdrawing");
77
+ }
78
+
79
+ // Determine input tree (all inputs must be from same tree)
80
+ let inputTreeIndex = currentTreeIndex;
81
+ if (inputUtxos.length > 0) {
82
+ inputTreeIndex = inputUtxos[0].treeIndex;
83
+ for (const utxo of inputUtxos) {
84
+ if (utxo.treeIndex !== inputTreeIndex) {
85
+ throw new Error("All input UTXOs must be from the same tree");
86
+ }
87
+ }
88
+ }
89
+
90
+ // Fetch leaves from input tree for merkle path computation
91
+ const leaves = await client.getCommitmentsForTree(inputTreeIndex);
92
+
93
+ // Get merkle root from input tree
94
+ let merkleRoot: bigint;
95
+ if (inputUtxos.length > 0) {
96
+ merkleRoot = await fetchMerkleRoot(inputTreeIndex, relayerUrl);
97
+ } else {
98
+ // Pure deposit - use current tree root
99
+ merkleRoot = await fetchMerkleRoot(currentTreeIndex, relayerUrl);
100
+ }
101
+
102
+ // Get next leaf index for output tree
103
+ const nextLeafIndex = await getNextLeafIndex(currentTreeIndex, relayerUrl);
104
+
105
+ // Calculate totals
106
+ const totalInputValue = inputUtxos.reduce((sum, u) => sum + u.value, 0n);
107
+ const totalAvailable = totalInputValue + depositAmount;
108
+
109
+ // Build output UTXOs
110
+ const mintBigInt = pubkeyToBigInt(request.mint);
111
+ const outputUtxos: UTXO[] = [];
112
+ let totalOutputValue = 0n;
113
+ let outputIndex = 0;
114
+
115
+ for (const output of outputs) {
116
+ const owner = output.owner === "self" ? walletKeys.owner : output.owner;
117
+ const utxo = createUTXO(
118
+ output.amount,
119
+ mintBigInt,
120
+ owner,
121
+ nextLeafIndex + outputIndex,
122
+ currentTreeIndex
123
+ );
124
+ outputUtxos.push(utxo);
125
+ totalOutputValue += output.amount;
126
+ outputIndex++;
127
+ }
128
+
129
+ // Calculate change
130
+ const totalOut = withdrawAmount + fee + totalOutputValue;
131
+ const changeAmount = totalAvailable - totalOut;
132
+
133
+ if (changeAmount < 0n) {
134
+ throw new Error(
135
+ `Insufficient value. Have ${totalAvailable} (inputs: ${totalInputValue}, deposit: ${depositAmount}), ` +
136
+ `need ${totalOut} (outputs: ${totalOutputValue}, withdraw: ${withdrawAmount}, fee: ${fee})`
137
+ );
138
+ }
139
+
140
+ // Add change output if needed
141
+ if (changeAmount > 0n) {
142
+ if (outputUtxos.length >= NUM_OUTPUT_UTXOS) {
143
+ throw new Error("No output slot available for change");
144
+ }
145
+ const changeUtxo = createUTXO(
146
+ changeAmount,
147
+ mintBigInt,
148
+ walletKeys.owner,
149
+ nextLeafIndex + outputIndex,
150
+ currentTreeIndex
151
+ );
152
+ outputUtxos.push(changeUtxo);
153
+ totalOutputValue += changeAmount;
154
+ }
155
+
156
+ // Pad outputs to NUM_OUTPUT_UTXOS
157
+ while (outputUtxos.length < NUM_OUTPUT_UTXOS) {
158
+ outputUtxos.push(createDummyUTXO());
159
+ }
160
+
161
+ // Build circuit inputs
162
+ const circuitInputs = buildCircuitInputs(
163
+ merkleRoot,
164
+ inputUtxos,
165
+ outputUtxos,
166
+ walletKeys,
167
+ leaves,
168
+ depositAmount,
169
+ withdrawAmount,
170
+ request.recipient ?? PublicKey.default,
171
+ request.mint,
172
+ fee
173
+ );
174
+
175
+ // Generate ZK proof
176
+ const { proof } = await generateProof(circuitInputs);
177
+
178
+ // Build public inputs for on-chain verifier
179
+ const nullifiers = circuitInputs.nullifiers.map(n => BigInt(n));
180
+ const publicInputs = {
181
+ merkleRoot: Array.from(bigIntToBytes32(merkleRoot)),
182
+ nullifiers: nullifiers.map(n => Array.from(bigIntToBytes32(n))),
183
+ outputCommitments: outputUtxos.map(u => Array.from(bigIntToBytes32(u.commitment))),
184
+ publicDeposit: depositAmount,
185
+ publicWithdraw: withdrawAmount,
186
+ recipient: request.recipient ?? PublicKey.default,
187
+ mintTokenAddress: request.mint,
188
+ fee,
189
+ };
190
+
191
+ // Create encrypted notes for outputs with encryption keys
192
+ const encryptedNotes: Buffer[] = [];
193
+ for (let i = 0; i < outputs.length; i++) {
194
+ const output = outputs[i];
195
+ const utxo = outputUtxos[i];
196
+
197
+ if (output.encryptionPubKey && utxo.value > 0n) {
198
+ const encrypted = encryptNote(
199
+ {
200
+ value: utxo.value,
201
+ mintTokenAddress: utxo.mintTokenAddress,
202
+ secret: utxo.secret,
203
+ leafIndex: utxo.leafIndex,
204
+ },
205
+ output.encryptionPubKey
206
+ );
207
+ encryptedNotes.push(Buffer.from(serializeEncryptedNote(encrypted)));
208
+ }
209
+ }
210
+
211
+ // Add encrypted note for change (to self)
212
+ if (changeAmount > 0n) {
213
+ const changeIndex = outputs.length;
214
+ const changeUtxo = outputUtxos[changeIndex];
215
+ const encrypted = encryptNote(
216
+ {
217
+ value: changeUtxo.value,
218
+ mintTokenAddress: changeUtxo.mintTokenAddress,
219
+ secret: changeUtxo.secret,
220
+ leafIndex: changeUtxo.leafIndex,
221
+ },
222
+ walletKeys.encryptionKeyPair.publicKey
223
+ );
224
+ encryptedNotes.push(Buffer.from(serializeEncryptedNote(encrypted)));
225
+ }
226
+
227
+ return {
228
+ proof,
229
+ publicInputs,
230
+ encryptedNotes,
231
+ outputUtxos: outputUtxos.filter(u => u.value > 0n),
232
+ nullifiers,
233
+ inputTreeIndex,
234
+ outputTreeIndex: currentTreeIndex,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Build circuit inputs from transaction data
240
+ */
241
+ function buildCircuitInputs(
242
+ merkleRoot: bigint,
243
+ inputUtxos: UTXO[],
244
+ outputUtxos: UTXO[],
245
+ walletKeys: WalletKeys,
246
+ leaves: bigint[],
247
+ depositAmount: bigint,
248
+ withdrawAmount: bigint,
249
+ recipient: PublicKey,
250
+ mint: PublicKey,
251
+ fee: bigint
252
+ ): CircuitInputs {
253
+ const inputSpendingKeys: string[] = [];
254
+ const inputValues: string[] = [];
255
+ const inputSecrets: string[] = [];
256
+ const inputLeafIndices: string[] = [];
257
+ const inputPathElements: string[][] = [];
258
+ const inputPathIndices: number[][] = [];
259
+ const nullifiers: string[] = [];
260
+
261
+ for (let i = 0; i < NUM_INPUT_UTXOS; i++) {
262
+ if (i < inputUtxos.length) {
263
+ const utxo = inputUtxos[i];
264
+ inputSpendingKeys.push(walletKeys.spendingKey.toString());
265
+ inputValues.push(utxo.value.toString());
266
+ inputSecrets.push(utxo.secret.toString());
267
+ inputLeafIndices.push(utxo.leafIndex.toString());
268
+
269
+ // Compute merkle path
270
+ const { pathElements, pathIndices } = computeMerklePathFromLeaves(
271
+ utxo.leafIndex,
272
+ leaves
273
+ );
274
+ inputPathElements.push(pathElements.map(e => e.toString()));
275
+ inputPathIndices.push(pathIndices);
276
+
277
+ // Compute nullifier
278
+ const nullifier = computeNullifier(
279
+ walletKeys.spendingKey,
280
+ utxo.commitment,
281
+ utxo.leafIndex
282
+ );
283
+ nullifiers.push(nullifier.toString());
284
+ } else {
285
+ // Dummy input
286
+ inputSpendingKeys.push("0");
287
+ inputValues.push("0");
288
+ inputSecrets.push("0");
289
+ inputLeafIndices.push("0");
290
+ inputPathElements.push(Array(MERKLE_TREE_DEPTH).fill("0"));
291
+ inputPathIndices.push(Array(MERKLE_TREE_DEPTH).fill(0));
292
+ nullifiers.push("0");
293
+ }
294
+ }
295
+
296
+ return {
297
+ merkleRoot: merkleRoot.toString(),
298
+ nullifiers,
299
+ outputCommitments: outputUtxos.map(u => u.commitment.toString()),
300
+ publicDeposit: depositAmount.toString(),
301
+ publicWithdraw: withdrawAmount.toString(),
302
+ recipient: pubkeyToBigInt(recipient).toString(),
303
+ mintTokenAddress: pubkeyToBigInt(mint).toString(),
304
+ fee: fee.toString(),
305
+
306
+ inputSpendingKeys,
307
+ inputValues,
308
+ inputSecrets,
309
+ inputLeafIndices,
310
+ inputPathElements,
311
+ inputPathIndices,
312
+
313
+ outputValues: outputUtxos.map(u => u.value.toString()),
314
+ outputOwners: outputUtxos.map(u => u.owner.toString()),
315
+ outputSecrets: outputUtxos.map(u => u.secret.toString()),
316
+ };
317
+ }
318
+
319
+ // ============================================================================
320
+ // Solana Instruction Building
321
+ // ============================================================================
322
+
323
+ /**
324
+ * Build Solana transaction accounts and instructions
325
+ *
326
+ * This creates the account list and any pre-instructions needed for the transaction.
327
+ */
328
+ export function buildTransactionAccounts(
329
+ builtTx: BuiltTransaction,
330
+ payer: PublicKey,
331
+ mint: PublicKey,
332
+ depositAmount: bigint,
333
+ withdrawAmount: bigint,
334
+ recipient?: PublicKey
335
+ ): {
336
+ accounts: {
337
+ payer: PublicKey;
338
+ mint: PublicKey;
339
+ pool: PublicKey;
340
+ inputTree: PublicKey;
341
+ merkleTree: PublicKey;
342
+ vault: PublicKey;
343
+ depositorTokenAccount: PublicKey | null;
344
+ depositor: PublicKey | null;
345
+ recipientTokenAccount: PublicKey | null;
346
+ feeRecipientTokenAccount: PublicKey | null;
347
+ nullifierAccount0: PublicKey | null;
348
+ nullifierAccount1: PublicKey | null;
349
+ tokenProgram: PublicKey;
350
+ systemProgram: PublicKey;
351
+ };
352
+ preInstructions: TransactionInstruction[];
353
+ inputTreeIndex: number | null;
354
+ } {
355
+ const [poolPda] = getPoolPDA();
356
+ const [vaultPda] = getVaultPDA(mint);
357
+ const [inputTreePda] = getMerkleTreePDA(builtTx.inputTreeIndex);
358
+ const [outputTreePda] = getMerkleTreePDA(builtTx.outputTreeIndex);
359
+
360
+ const preInstructions: TransactionInstruction[] = [];
361
+
362
+ // Depositor token account
363
+ let depositorTokenAccount: PublicKey | null = null;
364
+ let depositor: PublicKey | null = null;
365
+ if (depositAmount > 0n) {
366
+ depositorTokenAccount = getAssociatedTokenAddressSync(
367
+ mint,
368
+ payer,
369
+ false,
370
+ TOKEN_2022_PROGRAM_ID
371
+ );
372
+ depositor = payer;
373
+ }
374
+
375
+ // Recipient token account
376
+ let recipientTokenAccount: PublicKey | null = null;
377
+ if (withdrawAmount > 0n && recipient) {
378
+ recipientTokenAccount = getAssociatedTokenAddressSync(
379
+ mint,
380
+ recipient,
381
+ false,
382
+ TOKEN_2022_PROGRAM_ID
383
+ );
384
+
385
+ // Create ATA if needed
386
+ const createAtaIx = createAssociatedTokenAccountIdempotentInstruction(
387
+ payer,
388
+ recipientTokenAccount,
389
+ recipient,
390
+ mint,
391
+ TOKEN_2022_PROGRAM_ID
392
+ );
393
+ preInstructions.push(createAtaIx);
394
+ }
395
+
396
+ // Nullifier PDAs
397
+ const nullifierPdas: (PublicKey | null)[] = [];
398
+ for (const nullifier of builtTx.nullifiers) {
399
+ if (nullifier === 0n) {
400
+ nullifierPdas.push(null);
401
+ } else {
402
+ const nullifierBytes = bigIntToBytes32(nullifier);
403
+ const [nullifierPda] = getNullifierPDA(nullifierBytes);
404
+ nullifierPdas.push(nullifierPda);
405
+ }
406
+ }
407
+
408
+ // Determine if we need to pass input tree index
409
+ const inputTreeIndex = builtTx.inputTreeIndex !== builtTx.outputTreeIndex
410
+ ? builtTx.inputTreeIndex
411
+ : null;
412
+
413
+ return {
414
+ accounts: {
415
+ payer,
416
+ mint,
417
+ pool: poolPda,
418
+ inputTree: inputTreePda,
419
+ merkleTree: outputTreePda,
420
+ vault: vaultPda,
421
+ depositorTokenAccount,
422
+ depositor,
423
+ recipientTokenAccount,
424
+ feeRecipientTokenAccount: null, // TODO: Add fee recipient support
425
+ nullifierAccount0: nullifierPdas[0] ?? null,
426
+ nullifierAccount1: nullifierPdas[1] ?? null,
427
+ tokenProgram: TOKEN_2022_PROGRAM_ID,
428
+ systemProgram: SystemProgram.programId,
429
+ },
430
+ preInstructions,
431
+ inputTreeIndex,
432
+ };
433
+ }
434
+
435
+ // ============================================================================
436
+ // Helper Types for Anchor
437
+ // ============================================================================
438
+
439
+ /**
440
+ * Convert built transaction to Anchor-compatible format
441
+ */
442
+ export function toAnchorPublicInputs(builtTx: BuiltTransaction): {
443
+ merkleRoot: number[];
444
+ nullifiers: number[][];
445
+ outputCommitments: number[][];
446
+ publicDeposit: BN;
447
+ publicWithdraw: BN;
448
+ recipient: PublicKey;
449
+ mintTokenAddress: PublicKey;
450
+ fee: BN;
451
+ } {
452
+ return {
453
+ merkleRoot: builtTx.publicInputs.merkleRoot,
454
+ nullifiers: builtTx.publicInputs.nullifiers,
455
+ outputCommitments: builtTx.publicInputs.outputCommitments,
456
+ publicDeposit: new BN(builtTx.publicInputs.publicDeposit.toString()),
457
+ publicWithdraw: new BN(builtTx.publicInputs.publicWithdraw.toString()),
458
+ recipient: builtTx.publicInputs.recipient,
459
+ mintTokenAddress: builtTx.publicInputs.mintTokenAddress,
460
+ fee: new BN(builtTx.publicInputs.fee.toString()),
461
+ };
462
+ }
463
+
464
+