@hula-privacy/mixer 0.1.0 → 0.3.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 CHANGED
@@ -193,7 +193,9 @@ export async function fetchMerkleRoot(
193
193
  ): Promise<bigint> {
194
194
  const client = getRelayerClient(relayerUrl);
195
195
  const tree = await client.getTree(treeIndex);
196
- return BigInt(tree.root);
196
+ // Handle hex strings with or without 0x prefix
197
+ const rootStr = tree.root.startsWith("0x") ? tree.root : `0x${tree.root}`;
198
+ return BigInt(rootStr);
197
199
  }
198
200
 
199
201
  /**
@@ -189,6 +189,7 @@ export async function buildTransaction(
189
189
  };
190
190
 
191
191
  // Create encrypted notes for outputs with encryption keys
192
+ // Note: leafIndex is omitted to save space - recipient looks it up via commitment
192
193
  const encryptedNotes: Buffer[] = [];
193
194
  for (let i = 0; i < outputs.length; i++) {
194
195
  const output = outputs[i];
@@ -200,7 +201,7 @@ export async function buildTransaction(
200
201
  value: utxo.value,
201
202
  mintTokenAddress: utxo.mintTokenAddress,
202
203
  secret: utxo.secret,
203
- leafIndex: utxo.leafIndex,
204
+ // leafIndex omitted - recipient queries relayer with computed commitment
204
205
  },
205
206
  output.encryptionPubKey
206
207
  );
@@ -217,7 +218,7 @@ export async function buildTransaction(
217
218
  value: changeUtxo.value,
218
219
  mintTokenAddress: changeUtxo.mintTokenAddress,
219
220
  secret: changeUtxo.secret,
220
- leafIndex: changeUtxo.leafIndex,
221
+ // leafIndex omitted - we already know it locally
221
222
  },
222
223
  walletKeys.encryptionKeyPair.publicKey
223
224
  );
@@ -460,5 +461,3 @@ export function toAnchorPublicInputs(builtTx: BuiltTransaction): {
460
461
  fee: new BN(builtTx.publicInputs.fee.toString()),
461
462
  };
462
463
  }
463
-
464
-
package/src/types.ts CHANGED
@@ -302,6 +302,8 @@ export interface HulaSDKConfig {
302
302
  rpcUrl: string;
303
303
  /** Relayer API endpoint */
304
304
  relayerUrl: string;
305
+ /** Program ID for the Hula Privacy program */
306
+ programId?: PublicKey;
305
307
  /** Path to circuit WASM file */
306
308
  circuitWasmPath?: string;
307
309
  /** Path to circuit zkey file */
package/src/utxo.ts CHANGED
@@ -11,9 +11,11 @@ import {
11
11
  deserializeEncryptedNote,
12
12
  generateSpendingKey,
13
13
  hexToBytes,
14
+ pubkeyToBigInt,
14
15
  } from "./crypto";
15
16
  import { DOMAIN_NULLIFIER } from "./constants";
16
17
  import type { UTXO, WalletKeys, SerializableUTXO, NoteData } from "./types";
18
+ import { PublicKey } from "@solana/web3.js";
17
19
 
18
20
  // ============================================================================
19
21
  // Commitment & Nullifier Computation
@@ -176,31 +178,42 @@ export function scanNotesForUTXOs(
176
178
  const decrypted = decryptNote(encryptedNote, walletKeys.encryptionKeyPair.secretKey);
177
179
  if (!decrypted) continue;
178
180
 
179
- // Successfully decrypted - this is our UTXO
181
+ // Get the mint address from the note metadata
182
+ const mintAddress = note.mint;
183
+ const mintTokenAddress = pubkeyToBigInt(new PublicKey(mintAddress));
184
+
185
+ // Compute commitment to find the leafIndex
180
186
  const commitment = computeCommitment(
181
187
  decrypted.value,
182
- decrypted.mintTokenAddress,
188
+ mintTokenAddress,
183
189
  walletKeys.owner,
184
190
  decrypted.secret
185
191
  );
186
192
 
187
- // Verify commitment exists on-chain (optional but recommended)
193
+ // Find leafIndex by matching commitment in on-chain data
194
+ let foundLeafIndex: number | undefined;
188
195
  const treeCommitments = commitments.get(note.treeIndex);
189
196
  if (treeCommitments) {
190
- const onChainCommitment = treeCommitments.get(decrypted.leafIndex);
191
- if (onChainCommitment !== undefined && onChainCommitment !== commitment) {
192
- console.warn(`Commitment mismatch for leaf ${decrypted.leafIndex} in tree ${note.treeIndex}`);
193
- continue;
197
+ for (const [leafIndex, onChainCommitment] of treeCommitments.entries()) {
198
+ if (onChainCommitment === commitment) {
199
+ foundLeafIndex = leafIndex;
200
+ break;
201
+ }
194
202
  }
195
203
  }
196
204
 
205
+ if (foundLeafIndex === undefined) {
206
+ // Commitment not found on-chain yet - skip for now
207
+ continue;
208
+ }
209
+
197
210
  foundUTXOs.push({
198
211
  value: decrypted.value.toString(),
199
- mintTokenAddress: decrypted.mintTokenAddress.toString(),
212
+ mintTokenAddress: mintTokenAddress.toString(),
200
213
  owner: walletKeys.owner.toString(),
201
214
  secret: decrypted.secret.toString(),
202
215
  commitment: commitment.toString(),
203
- leafIndex: decrypted.leafIndex,
216
+ leafIndex: foundLeafIndex,
204
217
  treeIndex: note.treeIndex,
205
218
  mint: note.mint,
206
219
  spent: false,
@@ -261,8 +274,10 @@ export async function syncUTXOs(
261
274
  commitments
262
275
  );
263
276
 
277
+ const allUTXOs = [...newUTXOs, ...existingUTXOs];
278
+
264
279
  // Check which existing UTXOs have been spent
265
- const unspentUTXOs = existingUTXOs.filter(u => !u.spent);
280
+ const unspentUTXOs = allUTXOs.filter(u => !u.spent);
266
281
  const nullifiersToCheck: string[] = [];
267
282
  const utxoByNullifier = new Map<string, string>();
268
283
 
@@ -273,7 +288,7 @@ export async function syncUTXOs(
273
288
  commitment,
274
289
  utxo.leafIndex
275
290
  );
276
- const nullifierStr = nullifier.toString();
291
+ const nullifierStr = nullifier.toString(16).padStart(64, '0');
277
292
  nullifiersToCheck.push(nullifierStr);
278
293
  utxoByNullifier.set(nullifierStr, utxo.commitment); // Use commitment as ID
279
294
  }
package/src/wallet.ts CHANGED
@@ -4,8 +4,13 @@
4
4
  * High-level wallet abstraction that manages keys, UTXOs, and syncing
5
5
  */
6
6
 
7
- import { PublicKey, Connection, Keypair, type TransactionInstruction } from "@solana/web3.js";
8
- import type { Program } from "@coral-xyz/anchor";
7
+ import { PublicKey, Connection, Keypair, SystemProgram, ComputeBudgetProgram, Transaction, type TransactionInstruction } from "@solana/web3.js";
8
+ import { AnchorProvider, Program, Wallet } from "@coral-xyz/anchor";
9
+ import HulaPrivacyIdl from "./idl";
10
+
11
+ // Compute budget for ZK proof verification (needs more than default 200k)
12
+ const COMPUTE_UNITS = 400_000;
13
+ const COMPUTE_UNIT_PRICE = 1; // microlamports per CU
9
14
 
10
15
  import {
11
16
  initPoseidon,
@@ -40,7 +45,9 @@ import type {
40
45
  export class HulaWallet {
41
46
  private connection: Connection;
42
47
  private relayerUrl: string;
48
+ private programId: PublicKey;
43
49
  private keys: WalletKeys;
50
+ private signer?: Keypair;
44
51
  private utxos: SerializableUTXO[] = [];
45
52
  private lastSyncedSlot: string = "0";
46
53
  private initialized: boolean = false;
@@ -48,11 +55,15 @@ export class HulaWallet {
48
55
  private constructor(
49
56
  connection: Connection,
50
57
  relayerUrl: string,
51
- keys: WalletKeys
58
+ programId: PublicKey,
59
+ keys: WalletKeys,
60
+ signer?: Keypair
52
61
  ) {
53
62
  this.connection = connection;
54
63
  this.relayerUrl = relayerUrl;
64
+ this.programId = programId;
55
65
  this.keys = keys;
66
+ this.signer = signer;
56
67
  }
57
68
 
58
69
  /**
@@ -63,12 +74,38 @@ export class HulaWallet {
63
74
 
64
75
  const connection = new Connection(config.rpcUrl, "confirmed");
65
76
  const relayerUrl = config.relayerUrl;
77
+ const programId = config.programId ?? new PublicKey("tnJ9AAxNau3BRBTe7NzXpv8DeYyiogvGd8YPnCiuarA");
66
78
  setDefaultRelayerUrl(relayerUrl);
67
79
 
68
80
  const spendingKey = generateSpendingKey();
69
81
  const keys = deriveKeys(spendingKey);
70
82
 
71
- const wallet = new HulaWallet(connection, relayerUrl, keys);
83
+ const wallet = new HulaWallet(connection, relayerUrl, programId, keys);
84
+ wallet.initialized = true;
85
+ return wallet;
86
+ }
87
+
88
+ /**
89
+ * Create a wallet from a Solana keypair
90
+ *
91
+ * Derives the spending key deterministically from the keypair's secret key.
92
+ */
93
+ static async fromKeypair(
94
+ keypair: Keypair,
95
+ config: HulaSDKConfig
96
+ ): Promise<HulaWallet> {
97
+ await initPoseidon();
98
+
99
+ const connection = new Connection(config.rpcUrl, "confirmed");
100
+ const relayerUrl = config.relayerUrl;
101
+ const programId = config.programId ?? new PublicKey("tnJ9AAxNau3BRBTe7NzXpv8DeYyiogvGd8YPnCiuarA");
102
+ setDefaultRelayerUrl(relayerUrl);
103
+
104
+ // Derive spending key from keypair's secret key
105
+ const spendingKey = deriveSpendingKeyFromSignature(keypair.secretKey);
106
+ const keys = deriveKeys(spendingKey);
107
+
108
+ const wallet = new HulaWallet(connection, relayerUrl, programId, keys, keypair);
72
109
  wallet.initialized = true;
73
110
  return wallet;
74
111
  }
@@ -84,11 +121,12 @@ export class HulaWallet {
84
121
 
85
122
  const connection = new Connection(config.rpcUrl, "confirmed");
86
123
  const relayerUrl = config.relayerUrl;
124
+ const programId = config.programId ?? new PublicKey("tnJ9AAxNau3BRBTe7NzXpv8DeYyiogvGd8YPnCiuarA");
87
125
  setDefaultRelayerUrl(relayerUrl);
88
126
 
89
127
  const keys = deriveKeys(spendingKey);
90
128
 
91
- const wallet = new HulaWallet(connection, relayerUrl, keys);
129
+ const wallet = new HulaWallet(connection, relayerUrl, programId, keys);
92
130
  wallet.initialized = true;
93
131
  return wallet;
94
132
  }
@@ -106,12 +144,13 @@ export class HulaWallet {
106
144
 
107
145
  const connection = new Connection(config.rpcUrl, "confirmed");
108
146
  const relayerUrl = config.relayerUrl;
147
+ const programId = config.programId ?? new PublicKey("tnJ9AAxNau3BRBTe7NzXpv8DeYyiogvGd8YPnCiuarA");
109
148
  setDefaultRelayerUrl(relayerUrl);
110
149
 
111
150
  const spendingKey = deriveSpendingKeyFromSignature(signature);
112
151
  const keys = deriveKeys(spendingKey);
113
152
 
114
- const wallet = new HulaWallet(connection, relayerUrl, keys);
153
+ const wallet = new HulaWallet(connection, relayerUrl, programId, keys);
115
154
  wallet.initialized = true;
116
155
  return wallet;
117
156
  }
@@ -130,12 +169,19 @@ export class HulaWallet {
130
169
  /**
131
170
  * Get wallet's owner hash as hex string
132
171
  */
133
- get ownerHex(): string {
134
- return this.keys.owner.toString(16).padStart(64, "0");
172
+ getOwnerHash(): string {
173
+ return "0x" + this.keys.owner.toString(16).padStart(64, "0");
174
+ }
175
+
176
+ /**
177
+ * Get wallet's encryption public key as hex string
178
+ */
179
+ getEncryptionPublicKey(): string {
180
+ return "0x" + Buffer.from(this.keys.encryptionKeyPair.publicKey).toString("hex");
135
181
  }
136
182
 
137
183
  /**
138
- * Get wallet's encryption public key
184
+ * Get wallet's encryption public key as bytes
139
185
  */
140
186
  get encryptionPublicKey(): Uint8Array {
141
187
  return this.keys.encryptionKeyPair.publicKey;
@@ -155,6 +201,27 @@ export class HulaWallet {
155
201
  return this.keys;
156
202
  }
157
203
 
204
+ /**
205
+ * Get the connection
206
+ */
207
+ getConnection(): Connection {
208
+ return this.connection;
209
+ }
210
+
211
+ /**
212
+ * Get the signer keypair (if available)
213
+ */
214
+ getSigner(): Keypair | undefined {
215
+ return this.signer;
216
+ }
217
+
218
+ /**
219
+ * Set the signer keypair
220
+ */
221
+ setSigner(signer: Keypair): void {
222
+ this.signer = signer;
223
+ }
224
+
158
225
  // ============================================================================
159
226
  // UTXO Management
160
227
  // ============================================================================
@@ -259,15 +326,21 @@ export class HulaWallet {
259
326
 
260
327
  /**
261
328
  * Deposit tokens into the privacy pool
329
+ *
330
+ * @param mint - Token mint address
331
+ * @param amount - Amount in raw token units
332
+ * @returns Transaction signature
262
333
  */
263
334
  async deposit(
264
335
  mint: PublicKey,
265
336
  amount: bigint
266
- ): Promise<{
267
- transaction: ReturnType<typeof buildTransaction> extends Promise<infer T> ? T : never;
268
- instructions: TransactionInstruction[];
269
- }> {
270
- const tx = await buildTransaction(
337
+ ): Promise<string> {
338
+ if (!this.signer) {
339
+ throw new Error("No signer available. Use fromKeypair() or setSigner() to set a signer.");
340
+ }
341
+
342
+ // Build the transaction with ZK proof
343
+ const builtTx = await buildTransaction(
271
344
  {
272
345
  mint,
273
346
  depositAmount: amount,
@@ -277,36 +350,76 @@ export class HulaWallet {
277
350
  this.relayerUrl
278
351
  );
279
352
 
353
+ // Build accounts and pre-instructions
280
354
  const { accounts, preInstructions, inputTreeIndex } = buildTransactionAccounts(
281
- tx,
282
- PublicKey.default, // Payer will be set by caller
355
+ builtTx,
356
+ this.signer.publicKey,
283
357
  mint,
284
358
  amount,
285
359
  0n
286
360
  );
287
361
 
288
- return { transaction: tx, instructions: preInstructions };
362
+ // Add compute budget instructions for ZK proof verification
363
+ const allPreInstructions = [
364
+ ComputeBudgetProgram.setComputeUnitLimit({ units: COMPUTE_UNITS }),
365
+ ComputeBudgetProgram.setComputeUnitPrice({ microLamports: COMPUTE_UNIT_PRICE }),
366
+ ...preInstructions,
367
+ ];
368
+
369
+ // Setup Anchor program
370
+ const wallet = new Wallet(this.signer);
371
+ const provider = new AnchorProvider(this.connection, wallet, { commitment: "confirmed" });
372
+
373
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
374
+ const program = new Program(HulaPrivacyIdl as any, provider);
375
+
376
+ // Convert public inputs to Anchor format
377
+ const publicInputs = toAnchorPublicInputs(builtTx);
378
+
379
+ // Submit the transaction
380
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
381
+ const signature = await (program.methods as any)
382
+ .transact(
383
+ inputTreeIndex,
384
+ Array.from(builtTx.proof),
385
+ publicInputs,
386
+ builtTx.encryptedNotes
387
+ )
388
+ .accounts(accounts)
389
+ .preInstructions(allPreInstructions)
390
+ .signers([this.signer])
391
+ .rpc();
392
+
393
+ return signature;
289
394
  }
290
395
 
291
396
  /**
292
- * Transfer tokens privately
397
+ * Transfer tokens privately to another user
398
+ *
399
+ * @param mint - Token mint address
400
+ * @param amount - Amount in raw token units
401
+ * @param recipientOwner - Recipient's owner hash (bigint)
402
+ * @param recipientEncryptionPubKey - Recipient's encryption public key
403
+ * @returns Transaction signature
293
404
  */
294
405
  async transfer(
295
406
  mint: PublicKey,
296
407
  amount: bigint,
297
408
  recipientOwner: bigint,
298
409
  recipientEncryptionPubKey: Uint8Array
299
- ): Promise<{
300
- transaction: ReturnType<typeof buildTransaction> extends Promise<infer T> ? T : never;
301
- instructions: TransactionInstruction[];
302
- }> {
410
+ ): Promise<string> {
411
+ if (!this.signer) {
412
+ throw new Error("No signer available. Use fromKeypair() or setSigner() to set a signer.");
413
+ }
414
+
303
415
  const mintBigInt = pubkeyToBigInt(mint);
304
416
  const unspent = this.getUnspentUTXOs().filter(
305
417
  u => u.mintTokenAddress === mintBigInt
306
418
  );
307
419
  const inputUtxos = selectUTXOs(unspent, amount, mintBigInt);
308
420
 
309
- const tx = await buildTransaction(
421
+ // Build the transaction with ZK proof
422
+ const builtTx = await buildTransaction(
310
423
  {
311
424
  mint,
312
425
  inputUtxos,
@@ -322,35 +435,83 @@ export class HulaWallet {
322
435
  this.relayerUrl
323
436
  );
324
437
 
325
- const { preInstructions } = buildTransactionAccounts(
326
- tx,
327
- PublicKey.default,
438
+ // Build accounts and pre-instructions
439
+ const { accounts, preInstructions, inputTreeIndex } = buildTransactionAccounts(
440
+ builtTx,
441
+ this.signer.publicKey,
328
442
  mint,
329
443
  0n,
330
444
  0n
331
445
  );
332
446
 
333
- return { transaction: tx, instructions: preInstructions };
447
+ // Add compute budget instructions for ZK proof verification
448
+ const allPreInstructions = [
449
+ ComputeBudgetProgram.setComputeUnitLimit({ units: COMPUTE_UNITS }),
450
+ ComputeBudgetProgram.setComputeUnitPrice({ microLamports: COMPUTE_UNIT_PRICE }),
451
+ ...preInstructions,
452
+ ];
453
+
454
+ // Setup Anchor program
455
+ const wallet = new Wallet(this.signer);
456
+ const provider = new AnchorProvider(this.connection, wallet, { commitment: "confirmed" });
457
+
458
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
459
+ const program = new Program(HulaPrivacyIdl as any, provider);
460
+
461
+ // Convert public inputs to Anchor format
462
+ const publicInputs = toAnchorPublicInputs(builtTx);
463
+
464
+ // Submit the transaction
465
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
466
+ const signature = await (program.methods as any)
467
+ .transact(
468
+ inputTreeIndex,
469
+ Array.from(builtTx.proof),
470
+ publicInputs,
471
+ builtTx.encryptedNotes
472
+ )
473
+ .accounts(accounts)
474
+ .preInstructions(allPreInstructions)
475
+ .signers([this.signer])
476
+ .rpc();
477
+
478
+ // Mark input UTXOs as spent locally
479
+ for (const utxo of inputUtxos) {
480
+ const serialized = this.utxos.find(u => u.commitment === utxo.commitment.toString());
481
+ if (serialized) {
482
+ serialized.spent = true;
483
+ serialized.spentTx = signature;
484
+ }
485
+ }
486
+
487
+ return signature;
334
488
  }
335
489
 
336
490
  /**
337
491
  * Withdraw tokens from the privacy pool to a public address
492
+ *
493
+ * @param mint - Token mint address
494
+ * @param amount - Amount in raw token units
495
+ * @param recipient - Public key to receive the tokens
496
+ * @returns Transaction signature
338
497
  */
339
498
  async withdraw(
340
499
  mint: PublicKey,
341
500
  amount: bigint,
342
501
  recipient: PublicKey
343
- ): Promise<{
344
- transaction: ReturnType<typeof buildTransaction> extends Promise<infer T> ? T : never;
345
- instructions: TransactionInstruction[];
346
- }> {
502
+ ): Promise<string> {
503
+ if (!this.signer) {
504
+ throw new Error("No signer available. Use fromKeypair() or setSigner() to set a signer.");
505
+ }
506
+
347
507
  const mintBigInt = pubkeyToBigInt(mint);
348
508
  const unspent = this.getUnspentUTXOs().filter(
349
509
  u => u.mintTokenAddress === mintBigInt
350
510
  );
351
511
  const inputUtxos = selectUTXOs(unspent, amount, mintBigInt);
352
512
 
353
- const tx = await buildTransaction(
513
+ // Build the transaction with ZK proof
514
+ const builtTx = await buildTransaction(
354
515
  {
355
516
  mint,
356
517
  inputUtxos,
@@ -361,16 +522,57 @@ export class HulaWallet {
361
522
  this.relayerUrl
362
523
  );
363
524
 
364
- const { preInstructions } = buildTransactionAccounts(
365
- tx,
366
- PublicKey.default,
525
+ // Build accounts and pre-instructions
526
+ const { accounts, preInstructions, inputTreeIndex } = buildTransactionAccounts(
527
+ builtTx,
528
+ this.signer.publicKey,
367
529
  mint,
368
530
  0n,
369
531
  amount,
370
532
  recipient
371
533
  );
372
534
 
373
- return { transaction: tx, instructions: preInstructions };
535
+ // Add compute budget instructions for ZK proof verification
536
+ const allPreInstructions = [
537
+ ComputeBudgetProgram.setComputeUnitLimit({ units: COMPUTE_UNITS }),
538
+ ComputeBudgetProgram.setComputeUnitPrice({ microLamports: COMPUTE_UNIT_PRICE }),
539
+ ...preInstructions,
540
+ ];
541
+
542
+ // Setup Anchor program
543
+ const wallet = new Wallet(this.signer);
544
+ const provider = new AnchorProvider(this.connection, wallet, { commitment: "confirmed" });
545
+
546
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
547
+ const program = new Program(HulaPrivacyIdl as any, provider);
548
+
549
+ // Convert public inputs to Anchor format
550
+ const publicInputs = toAnchorPublicInputs(builtTx);
551
+
552
+ // Submit the transaction
553
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
554
+ const signature = await (program.methods as any)
555
+ .transact(
556
+ inputTreeIndex,
557
+ Array.from(builtTx.proof),
558
+ publicInputs,
559
+ builtTx.encryptedNotes
560
+ )
561
+ .accounts(accounts)
562
+ .preInstructions(allPreInstructions)
563
+ .signers([this.signer])
564
+ .rpc();
565
+
566
+ // Mark input UTXOs as spent locally
567
+ for (const utxo of inputUtxos) {
568
+ const serialized = this.utxos.find(u => u.commitment === utxo.commitment.toString());
569
+ if (serialized) {
570
+ serialized.spent = true;
571
+ serialized.spentTx = signature;
572
+ }
573
+ }
574
+
575
+ return signature;
374
576
  }
375
577
 
376
578
  /**