@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.
package/src/merkle.ts ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Merkle Tree utilities for Hula Privacy Protocol
3
+ *
4
+ * Provides functions for building merkle paths from relayer data
5
+ */
6
+
7
+ import { MERKLE_TREE_DEPTH } from "./constants";
8
+ import { poseidonHash, getPoseidon, isPoseidonInitialized } from "./crypto";
9
+ import { getRelayerClient } from "./api";
10
+ import type { MerklePath, LeafData } from "./types";
11
+
12
+ // ============================================================================
13
+ // Zero Values
14
+ // ============================================================================
15
+
16
+ let ZEROS: bigint[] = [];
17
+
18
+ /**
19
+ * Compute zero hashes for the merkle tree using Poseidon
20
+ * ZEROS[0] = 0 (empty leaf)
21
+ * ZEROS[i+1] = Poseidon(ZEROS[i], ZEROS[i])
22
+ */
23
+ export function computeZeros(): bigint[] {
24
+ if (!isPoseidonInitialized()) {
25
+ throw new Error("Poseidon not initialized. Call initPoseidon() first.");
26
+ }
27
+
28
+ if (ZEROS.length > 0) {
29
+ return ZEROS;
30
+ }
31
+
32
+ const zeros: bigint[] = [0n]; // ZEROS[0] = 0 (empty leaf)
33
+ const poseidon = getPoseidon();
34
+
35
+ for (let i = 0; i < MERKLE_TREE_DEPTH; i++) {
36
+ const hash = poseidon([zeros[i], zeros[i]]);
37
+ zeros.push(poseidon.F.toObject(hash));
38
+ }
39
+
40
+ ZEROS = zeros;
41
+ return zeros;
42
+ }
43
+
44
+ /**
45
+ * Get zero value at a specific level
46
+ */
47
+ export function getZeroAtLevel(level: number): bigint {
48
+ if (ZEROS.length === 0) {
49
+ computeZeros();
50
+ }
51
+ return ZEROS[level];
52
+ }
53
+
54
+ /**
55
+ * Get the root of an empty merkle tree
56
+ */
57
+ export function getEmptyTreeRoot(): bigint {
58
+ if (ZEROS.length === 0) {
59
+ computeZeros();
60
+ }
61
+ return ZEROS[MERKLE_TREE_DEPTH];
62
+ }
63
+
64
+ // ============================================================================
65
+ // Merkle Path Computation
66
+ // ============================================================================
67
+
68
+ /**
69
+ * Compute merkle path from a list of leaves
70
+ *
71
+ * This builds the full tree locally and extracts the path for the given leaf.
72
+ * Use this when you have fetched all leaves from the relayer.
73
+ */
74
+ export function computeMerklePathFromLeaves(
75
+ leafIndex: number,
76
+ leaves: bigint[]
77
+ ): MerklePath {
78
+ if (!isPoseidonInitialized()) {
79
+ throw new Error("Poseidon not initialized. Call initPoseidon() first.");
80
+ }
81
+
82
+ const zeros = computeZeros();
83
+ const pathElements: bigint[] = [];
84
+ const pathIndices: number[] = [];
85
+ const poseidon = getPoseidon();
86
+
87
+ // Build tree level by level
88
+ let currentLevel = [...leaves];
89
+
90
+ // Pad to tree size with zeros
91
+ const treeSize = 1 << MERKLE_TREE_DEPTH;
92
+ while (currentLevel.length < treeSize) {
93
+ currentLevel.push(zeros[0]);
94
+ }
95
+
96
+ let targetIndex = leafIndex;
97
+
98
+ for (let level = 0; level < MERKLE_TREE_DEPTH; level++) {
99
+ const isLeft = (targetIndex & 1) === 0;
100
+ pathIndices.push(isLeft ? 0 : 1);
101
+
102
+ // Get sibling
103
+ const siblingIndex = isLeft ? targetIndex + 1 : targetIndex - 1;
104
+ pathElements.push(currentLevel[siblingIndex]);
105
+
106
+ // Compute next level
107
+ const nextLevel: bigint[] = [];
108
+ for (let i = 0; i < currentLevel.length; i += 2) {
109
+ const left = currentLevel[i];
110
+ const right = currentLevel[i + 1] ?? zeros[level];
111
+ const hash = poseidon([left, right]);
112
+ nextLevel.push(poseidon.F.toObject(hash));
113
+ }
114
+ currentLevel = nextLevel;
115
+ targetIndex = Math.floor(targetIndex / 2);
116
+ }
117
+
118
+ return { pathElements, pathIndices, root: currentLevel[0] };
119
+ }
120
+
121
+ /**
122
+ * Compute merkle root from leaves
123
+ */
124
+ export function computeMerkleRoot(leaves: bigint[]): bigint {
125
+ if (leaves.length === 0) {
126
+ return getEmptyTreeRoot();
127
+ }
128
+
129
+ const { root } = computeMerklePathFromLeaves(0, leaves);
130
+ return root;
131
+ }
132
+
133
+ /**
134
+ * Verify a merkle path is valid
135
+ */
136
+ export function verifyMerklePath(
137
+ leafValue: bigint,
138
+ path: MerklePath
139
+ ): boolean {
140
+ if (!isPoseidonInitialized()) {
141
+ throw new Error("Poseidon not initialized. Call initPoseidon() first.");
142
+ }
143
+
144
+ let current = leafValue;
145
+ const poseidon = getPoseidon();
146
+
147
+ for (let i = 0; i < path.pathElements.length; i++) {
148
+ const sibling = path.pathElements[i];
149
+ const isLeft = path.pathIndices[i] === 0;
150
+
151
+ const left = isLeft ? current : sibling;
152
+ const right = isLeft ? sibling : current;
153
+
154
+ const hash = poseidon([left, right]);
155
+ current = poseidon.F.toObject(hash);
156
+ }
157
+
158
+ return current === path.root;
159
+ }
160
+
161
+ // ============================================================================
162
+ // Relayer Integration
163
+ // ============================================================================
164
+
165
+ /**
166
+ * Fetch merkle path for a UTXO from the relayer
167
+ *
168
+ * This fetches all leaves for the tree and computes the path locally.
169
+ */
170
+ export async function fetchMerklePath(
171
+ treeIndex: number,
172
+ leafIndex: number,
173
+ relayerUrl?: string
174
+ ): Promise<MerklePath> {
175
+ const client = getRelayerClient(relayerUrl);
176
+
177
+ // Get all leaves for this tree
178
+ const leaves = await client.getCommitmentsForTree(treeIndex);
179
+
180
+ if (leafIndex >= leaves.length) {
181
+ throw new Error(`Leaf index ${leafIndex} not found in tree ${treeIndex} (has ${leaves.length} leaves)`);
182
+ }
183
+
184
+ return computeMerklePathFromLeaves(leafIndex, leaves);
185
+ }
186
+
187
+ /**
188
+ * Fetch current merkle root for a tree from the relayer
189
+ */
190
+ export async function fetchMerkleRoot(
191
+ treeIndex: number,
192
+ relayerUrl?: string
193
+ ): Promise<bigint> {
194
+ const client = getRelayerClient(relayerUrl);
195
+ const tree = await client.getTree(treeIndex);
196
+ return BigInt(tree.root);
197
+ }
198
+
199
+ /**
200
+ * Get next leaf index for a tree
201
+ */
202
+ export async function getNextLeafIndex(
203
+ treeIndex: number,
204
+ relayerUrl?: string
205
+ ): Promise<number> {
206
+ const client = getRelayerClient(relayerUrl);
207
+ const tree = await client.getTree(treeIndex);
208
+ return tree.nextIndex;
209
+ }
210
+
211
+ /**
212
+ * Get current active tree index
213
+ */
214
+ export async function getCurrentTreeIndex(relayerUrl?: string): Promise<number> {
215
+ const client = getRelayerClient(relayerUrl);
216
+ const pool = await client.getPool();
217
+ return pool.currentTreeIndex;
218
+ }
219
+
220
+
package/src/proof.ts ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * ZK Proof generation utilities
3
+ *
4
+ * Uses snarkjs to generate Groth16 proofs for the transaction circuit
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
8
+ import { execSync } from "child_process";
9
+ import path from "path";
10
+ import { PROOF_SIZE } from "./constants";
11
+ import type { CircuitInputs } from "./types";
12
+
13
+ // ============================================================================
14
+ // Circuit Paths
15
+ // ============================================================================
16
+
17
+ let circuitWasmPath: string | null = null;
18
+ let circuitZkeyPath: string | null = null;
19
+
20
+ /**
21
+ * Set circuit file paths
22
+ */
23
+ export function setCircuitPaths(wasmPath: string, zkeyPath: string): void {
24
+ circuitWasmPath = wasmPath;
25
+ circuitZkeyPath = zkeyPath;
26
+ }
27
+
28
+ /**
29
+ * Get circuit file paths (with fallback to default locations)
30
+ */
31
+ export function getCircuitPaths(): { wasmPath: string; zkeyPath: string } {
32
+ if (circuitWasmPath && circuitZkeyPath) {
33
+ return { wasmPath: circuitWasmPath, zkeyPath: circuitZkeyPath };
34
+ }
35
+
36
+ // Try to find circuits in common locations
37
+ const possibleBasePaths = [
38
+ path.join(process.cwd(), "circuits", "build"),
39
+ path.join(process.cwd(), "..", "circuits", "build"),
40
+ path.join(process.cwd(), "assets"),
41
+ ];
42
+
43
+ for (const basePath of possibleBasePaths) {
44
+ const wasmPath = path.join(basePath, "transaction_js", "transaction.wasm");
45
+ const zkeyPath = path.join(basePath, "keys", "transaction_final.zkey");
46
+
47
+ if (existsSync(wasmPath) && existsSync(zkeyPath)) {
48
+ return { wasmPath, zkeyPath };
49
+ }
50
+
51
+ // Also check if files are directly in assets folder
52
+ const altWasmPath = path.join(basePath, "transaction.wasm");
53
+ const altZkeyPath = path.join(basePath, "transaction_final.zkey");
54
+
55
+ if (existsSync(altWasmPath) && existsSync(altZkeyPath)) {
56
+ return { wasmPath: altWasmPath, zkeyPath: altZkeyPath };
57
+ }
58
+ }
59
+
60
+ throw new Error(
61
+ "Circuit files not found. Either:\n" +
62
+ "1. Run 'make' in circuits/ directory to build them, or\n" +
63
+ "2. Call setCircuitPaths() with the correct paths"
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Verify circuit files exist
69
+ */
70
+ export function verifyCircuitFiles(): void {
71
+ const { wasmPath, zkeyPath } = getCircuitPaths();
72
+
73
+ if (!existsSync(wasmPath)) {
74
+ throw new Error(`Circuit WASM not found: ${wasmPath}`);
75
+ }
76
+
77
+ if (!existsSync(zkeyPath)) {
78
+ throw new Error(`Circuit zkey not found: ${zkeyPath}`);
79
+ }
80
+ }
81
+
82
+ // ============================================================================
83
+ // Proof Parsing
84
+ // ============================================================================
85
+
86
+ /**
87
+ * Convert a decimal string to 32-byte big-endian Uint8Array
88
+ */
89
+ function toBigEndianBytes(decimalStr: string): Uint8Array {
90
+ let hex = BigInt(decimalStr).toString(16);
91
+ hex = hex.padStart(64, "0");
92
+ const bytes = new Uint8Array(32);
93
+ for (let i = 0; i < 32; i++) {
94
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
95
+ }
96
+ return bytes;
97
+ }
98
+
99
+ /**
100
+ * Parse snarkjs proof JSON into 256-byte proof array
101
+ *
102
+ * Format: proof_a (64 bytes) + proof_b (128 bytes) + proof_c (64 bytes)
103
+ */
104
+ export function parseProof(proofJson: {
105
+ pi_a: string[];
106
+ pi_b: string[][];
107
+ pi_c: string[];
108
+ }): Uint8Array {
109
+ const proof = new Uint8Array(PROOF_SIZE);
110
+
111
+ // proof_a: G1 point (x, y) - 64 bytes
112
+ const pi_a_x = toBigEndianBytes(proofJson.pi_a[0]);
113
+ const pi_a_y = toBigEndianBytes(proofJson.pi_a[1]);
114
+ proof.set(pi_a_x, 0);
115
+ proof.set(pi_a_y, 32);
116
+
117
+ // proof_b: G2 point - 128 bytes
118
+ // G2 points have coordinates in extension field: [[x0, x1], [y0, y1]]
119
+ // Order for Solana/Solidity verifier: x1 || x0 || y1 || y0
120
+ const pi_b_x0 = toBigEndianBytes(proofJson.pi_b[0][1]);
121
+ const pi_b_x1 = toBigEndianBytes(proofJson.pi_b[0][0]);
122
+ const pi_b_y0 = toBigEndianBytes(proofJson.pi_b[1][1]);
123
+ const pi_b_y1 = toBigEndianBytes(proofJson.pi_b[1][0]);
124
+ proof.set(pi_b_x0, 64);
125
+ proof.set(pi_b_x1, 96);
126
+ proof.set(pi_b_y0, 128);
127
+ proof.set(pi_b_y1, 160);
128
+
129
+ // proof_c: G1 point (x, y) - 64 bytes
130
+ const pi_c_x = toBigEndianBytes(proofJson.pi_c[0]);
131
+ const pi_c_y = toBigEndianBytes(proofJson.pi_c[1]);
132
+ proof.set(pi_c_x, 192);
133
+ proof.set(pi_c_y, 224);
134
+
135
+ return proof;
136
+ }
137
+
138
+ // ============================================================================
139
+ // Proof Generation
140
+ // ============================================================================
141
+
142
+ /**
143
+ * Generate ZK proof using snarkjs CLI (subprocess for Bun compatibility)
144
+ *
145
+ * This spawns snarkjs as a subprocess to avoid web-worker issues with Bun.
146
+ * Works in both Node.js and Bun environments.
147
+ */
148
+ export async function generateProof(
149
+ circuitInputs: CircuitInputs
150
+ ): Promise<{ proof: Uint8Array; publicSignals: string[] }> {
151
+ verifyCircuitFiles();
152
+ const { wasmPath, zkeyPath } = getCircuitPaths();
153
+
154
+ // Get temp directory
155
+ const tempDir = path.dirname(zkeyPath);
156
+
157
+ // Create unique temp file names
158
+ const timestamp = Date.now();
159
+ const inputPath = path.join(tempDir, `temp_input_${timestamp}.json`);
160
+ const witnessPath = path.join(tempDir, `temp_witness_${timestamp}.wtns`);
161
+ const proofPath = path.join(tempDir, `temp_proof_${timestamp}.json`);
162
+ const publicPath = path.join(tempDir, `temp_public_${timestamp}.json`);
163
+
164
+ try {
165
+ // Write input to temp file
166
+ writeFileSync(inputPath, JSON.stringify(circuitInputs));
167
+
168
+ // Find witness generation script
169
+ const witnessGenScript = path.join(
170
+ path.dirname(wasmPath),
171
+ "generate_witness.cjs"
172
+ );
173
+
174
+ if (!existsSync(witnessGenScript)) {
175
+ throw new Error(`Witness generation script not found: ${witnessGenScript}`);
176
+ }
177
+
178
+ // Generate witness using node (not bun) to avoid web-worker issues
179
+ execSync(`node ${witnessGenScript} ${wasmPath} ${inputPath} ${witnessPath}`, {
180
+ stdio: "pipe",
181
+ cwd: tempDir,
182
+ });
183
+
184
+ // Generate proof using snarkjs CLI
185
+ execSync(
186
+ `npx snarkjs groth16 prove ${zkeyPath} ${witnessPath} ${proofPath} ${publicPath}`,
187
+ {
188
+ stdio: "pipe",
189
+ cwd: tempDir,
190
+ }
191
+ );
192
+
193
+ // Read proof and public signals
194
+ const proofJson = JSON.parse(readFileSync(proofPath, "utf-8"));
195
+ const publicSignals = JSON.parse(readFileSync(publicPath, "utf-8"));
196
+
197
+ const proof = parseProof(proofJson);
198
+
199
+ return { proof, publicSignals };
200
+ } finally {
201
+ // Cleanup temp files
202
+ const cleanup = (filePath: string) => {
203
+ try {
204
+ if (existsSync(filePath)) unlinkSync(filePath);
205
+ } catch {}
206
+ };
207
+
208
+ cleanup(inputPath);
209
+ cleanup(witnessPath);
210
+ cleanup(proofPath);
211
+ cleanup(publicPath);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Generate proof using snarkjs in-memory (for Node.js environments)
217
+ *
218
+ * This is more efficient but may not work in all environments.
219
+ */
220
+ export async function generateProofInMemory(
221
+ circuitInputs: CircuitInputs
222
+ ): Promise<{ proof: Uint8Array; publicSignals: string[] }> {
223
+ verifyCircuitFiles();
224
+ const { wasmPath, zkeyPath } = getCircuitPaths();
225
+
226
+ // Dynamic import to handle environments where snarkjs isn't available
227
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
228
+ const snarkjs = await import("snarkjs" as string) as {
229
+ groth16: {
230
+ fullProve: (
231
+ inputs: CircuitInputs,
232
+ wasmPath: string,
233
+ zkeyPath: string
234
+ ) => Promise<{
235
+ proof: { pi_a: string[]; pi_b: string[][]; pi_c: string[] };
236
+ publicSignals: string[];
237
+ }>;
238
+ };
239
+ };
240
+
241
+ const { proof: proofData, publicSignals } = await snarkjs.groth16.fullProve(
242
+ circuitInputs,
243
+ wasmPath,
244
+ zkeyPath
245
+ );
246
+
247
+ const proof = parseProof(proofData);
248
+
249
+ return { proof, publicSignals };
250
+ }
251
+