@veil-cash/sdk 0.6.1 → 0.6.3

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/subaccount.ts CHANGED
@@ -12,14 +12,21 @@ import {
12
12
  } from 'viem';
13
13
  import { privateKeyToAccount, privateKeyToAddress } from 'viem/accounts';
14
14
  import { base } from 'viem/chains';
15
- import { FORWARDER_ABI, FORWARDER_FACTORY_ABI, ERC20_ABI } from './abi.js';
16
- import { FORWARDER_CONTRACT_VERSION, getAddresses, getForwarderFactoryAddress } from './addresses.js';
17
- import { getQueueBalance } from './balance.js';
15
+ import { FORWARDER_ABI, FORWARDER_FACTORY_ABI, ERC20_ABI, POOL_ABI } from './abi.js';
16
+ import { FORWARDER_CONTRACT_VERSION, getAddresses, getForwarderFactoryAddress, getPoolAddress, POOL_CONFIG } from './addresses.js';
17
+ import { getPrivateBalance, getQueueBalance } from './balance.js';
18
18
  import { Keypair } from './keypair.js';
19
- import { postRelayJson } from './relay.js';
19
+ import { postRelayJson, submitRelay } from './relay.js';
20
+ import { prepareTransaction } from './transaction.js';
21
+ import { Utxo } from './utxo.js';
22
+ import { selectUtxosForWithdraw } from './withdraw.js';
20
23
  import type {
21
24
  SubaccountAsset,
22
25
  SubaccountDeployRequest,
26
+ SubaccountMergeOptions,
27
+ SubaccountMergeResult,
28
+ PrivateBalanceResult,
29
+ SubaccountPrivateBalanceStatus,
23
30
  SubaccountQueueStatus,
24
31
  SubaccountRecoveryResult,
25
32
  SubaccountRelayResult,
@@ -238,6 +245,37 @@ function toQueueStatus(
238
245
  };
239
246
  }
240
247
 
248
+ function toPrivateBalanceStatus(result: PrivateBalanceResult): SubaccountPrivateBalanceStatus {
249
+ return {
250
+ privateBalance: result.privateBalance,
251
+ privateBalanceWei: result.privateBalanceWei,
252
+ utxoCount: result.utxoCount,
253
+ spentCount: result.spentCount,
254
+ unspentCount: result.unspentCount,
255
+ };
256
+ }
257
+
258
+ export async function getSubaccountPrivateBalance(options: {
259
+ rootPrivateKey: `0x${string}`;
260
+ slot: number;
261
+ pool?: 'eth' | 'usdc';
262
+ rpcUrl?: string;
263
+ onProgress?: (stage: string, detail?: string) => void;
264
+ }): Promise<PrivateBalanceResult> {
265
+ const normalizedSlot = normalizeSlot(options.slot);
266
+ assertPrivateKey(options.rootPrivateKey, 'rootPrivateKey');
267
+
268
+ const childPrivateKey = deriveSubaccountChildPrivateKey(options.rootPrivateKey, normalizedSlot);
269
+ const childKeypair = new Keypair(childPrivateKey);
270
+
271
+ return getPrivateBalance({
272
+ keypair: childKeypair,
273
+ pool: options.pool,
274
+ rpcUrl: options.rpcUrl,
275
+ onProgress: options.onProgress,
276
+ });
277
+ }
278
+
241
279
  export async function getSubaccountStatus(options: {
242
280
  rootPrivateKey: `0x${string}`;
243
281
  slot: number;
@@ -247,7 +285,7 @@ export async function getSubaccountStatus(options: {
247
285
  const publicClient = createBaseClient(options.rpcUrl);
248
286
  const addresses = getAddresses();
249
287
 
250
- const [deployed, ethWei, usdcWei, ethQueue, usdcQueue] = await Promise.all([
288
+ const [deployed, ethWei, usdcWei, ethQueue, usdcQueue, ethPrivate, usdcPrivate] = await Promise.all([
251
289
  isSubaccountForwarderDeployed({
252
290
  forwarderAddress: slot.forwarderAddress,
253
291
  rpcUrl: options.rpcUrl,
@@ -269,6 +307,18 @@ export async function getSubaccountStatus(options: {
269
307
  pool: 'usdc',
270
308
  rpcUrl: options.rpcUrl,
271
309
  }),
310
+ getSubaccountPrivateBalance({
311
+ rootPrivateKey: options.rootPrivateKey,
312
+ slot: options.slot,
313
+ pool: 'eth',
314
+ rpcUrl: options.rpcUrl,
315
+ }),
316
+ getSubaccountPrivateBalance({
317
+ rootPrivateKey: options.rootPrivateKey,
318
+ slot: options.slot,
319
+ pool: 'usdc',
320
+ rpcUrl: options.rpcUrl,
321
+ }),
272
322
  ]);
273
323
 
274
324
  return {
@@ -284,6 +334,10 @@ export async function getSubaccountStatus(options: {
284
334
  balanceWei: usdcWei.toString(),
285
335
  },
286
336
  },
337
+ privateBalances: {
338
+ eth: toPrivateBalanceStatus(ethPrivate),
339
+ usdc: toPrivateBalanceStatus(usdcPrivate),
340
+ },
287
341
  queues: {
288
342
  eth: toQueueStatus('eth', ethQueue),
289
343
  usdc: toQueueStatus('usdc', usdcQueue),
@@ -479,3 +533,188 @@ export async function buildSubaccountRecoveryTx(options: {
479
533
  signature,
480
534
  };
481
535
  }
536
+
537
+ /**
538
+ * Merge a subaccount's entire private balance back to the main wallet.
539
+ *
540
+ * Builds a ZK transfer proof that moves every unspent UTXO belonging to the
541
+ * child keypair into a new UTXO encrypted to the parent (root) keypair,
542
+ * then submits it via the relay.
543
+ *
544
+ * @param options - Merge options
545
+ * @returns Merge result with transaction hash and amount
546
+ *
547
+ * @example
548
+ * ```typescript
549
+ * const result = await mergeSubaccount({
550
+ * rootPrivateKey: process.env.VEIL_KEY as `0x${string}`,
551
+ * slot: 0,
552
+ * pool: 'eth',
553
+ * });
554
+ * console.log(`Merged ${result.amount} — tx: ${result.transactionHash}`);
555
+ * ```
556
+ */
557
+ export async function mergeSubaccount(
558
+ options: SubaccountMergeOptions,
559
+ ): Promise<SubaccountMergeResult> {
560
+ const {
561
+ rootPrivateKey,
562
+ slot,
563
+ pool = 'eth',
564
+ rpcUrl,
565
+ relayUrl,
566
+ onProgress,
567
+ } = options;
568
+
569
+ const normalizedSlot = normalizeSlot(slot);
570
+ assertPrivateKey(rootPrivateKey, 'rootPrivateKey');
571
+
572
+ const poolConfig = POOL_CONFIG[pool];
573
+ const poolAddress = getPoolAddress(pool);
574
+
575
+ // Derive child and parent keypairs
576
+ const childPrivateKey = deriveSubaccountChildPrivateKey(rootPrivateKey, normalizedSlot);
577
+ const childKeypair = new Keypair(childPrivateKey);
578
+ const parentKeypair = new Keypair(rootPrivateKey);
579
+
580
+ // Fetch child's private balance
581
+ onProgress?.('Fetching subaccount balance...');
582
+ const balanceResult = await getPrivateBalance({
583
+ keypair: childKeypair,
584
+ pool,
585
+ rpcUrl,
586
+ onProgress,
587
+ });
588
+
589
+ const unspentUtxoInfos = balanceResult.utxos.filter(u => !u.isSpent);
590
+ if (unspentUtxoInfos.length === 0) {
591
+ throw new Error('Subaccount has no unspent UTXOs to merge');
592
+ }
593
+ if (unspentUtxoInfos.length > 16) {
594
+ throw new Error(
595
+ `Subaccount has ${unspentUtxoInfos.length} unspent UTXOs which exceeds the 16-input circuit limit. ` +
596
+ 'Consolidate UTXOs on the subaccount first before merging.',
597
+ );
598
+ }
599
+
600
+ // Re-decrypt UTXOs to get full Utxo objects
601
+ onProgress?.('Preparing UTXOs...');
602
+ const publicClient = createPublicClient({
603
+ chain: base,
604
+ transport: http(rpcUrl),
605
+ });
606
+
607
+ const utxos: Utxo[] = [];
608
+ for (const utxoInfo of unspentUtxoInfos) {
609
+ const encryptedOutputs = await publicClient.readContract({
610
+ address: poolAddress,
611
+ abi: POOL_ABI,
612
+ functionName: 'getEncryptedOutputs',
613
+ args: [BigInt(utxoInfo.index), BigInt(utxoInfo.index + 1)],
614
+ }) as string[];
615
+
616
+ if (encryptedOutputs.length > 0) {
617
+ try {
618
+ const utxo = Utxo.decrypt(encryptedOutputs[0], childKeypair);
619
+ utxo.index = utxoInfo.index;
620
+ utxos.push(utxo);
621
+ } catch {
622
+ // Skip if decryption fails
623
+ }
624
+ }
625
+ }
626
+
627
+ if (utxos.length === 0) {
628
+ throw new Error('Failed to decrypt subaccount UTXOs');
629
+ }
630
+
631
+ // Select all UTXOs — transfer the full balance
632
+ onProgress?.('Selecting UTXOs...');
633
+ const amount = balanceResult.privateBalance;
634
+ const { selectedUtxos, changeAmount } = selectUtxosForWithdraw(
635
+ utxos,
636
+ amount,
637
+ poolConfig.decimals,
638
+ );
639
+
640
+ // Create output UTXO encrypted to the parent keypair
641
+ const outputs: Utxo[] = [];
642
+ const mergeWei = parseUnits(amount, poolConfig.decimals);
643
+
644
+ outputs.push(new Utxo({ amount: mergeWei, keypair: parentKeypair }));
645
+
646
+ if (changeAmount > 0n) {
647
+ outputs.push(new Utxo({ amount: changeAmount, keypair: parentKeypair }));
648
+ }
649
+
650
+ // Fetch all commitments from pool
651
+ onProgress?.('Fetching commitments...');
652
+ const nextIndex = await publicClient.readContract({
653
+ address: poolAddress,
654
+ abi: POOL_ABI,
655
+ functionName: 'nextIndex',
656
+ }) as number;
657
+
658
+ const BATCH_SIZE = 5000;
659
+ const commitments: string[] = [];
660
+ const totalBatches = Math.ceil(nextIndex / BATCH_SIZE);
661
+
662
+ for (let start = 0; start < nextIndex; start += BATCH_SIZE) {
663
+ const end = Math.min(start + BATCH_SIZE, nextIndex);
664
+ const batchNum = Math.floor(start / BATCH_SIZE) + 1;
665
+ onProgress?.('Fetching commitments', `batch ${batchNum}/${totalBatches}`);
666
+
667
+ const batch = await publicClient.readContract({
668
+ address: poolAddress,
669
+ abi: POOL_ABI,
670
+ functionName: 'getCommitments',
671
+ args: [BigInt(start), BigInt(end)],
672
+ }) as `0x${string}`[];
673
+
674
+ commitments.push(...batch.map(c => c.toString()));
675
+ }
676
+
677
+ // Build ZK proof (recipient = 0x0 for in-pool transfer)
678
+ onProgress?.('Building ZK proof...');
679
+ const result = await prepareTransaction({
680
+ commitments,
681
+ inputs: selectedUtxos,
682
+ outputs,
683
+ fee: 0,
684
+ recipient: '0x0000000000000000000000000000000000000000',
685
+ relayer: '0x0000000000000000000000000000000000000000',
686
+ onProgress,
687
+ });
688
+
689
+ // Submit to relay
690
+ onProgress?.('Submitting to relay...');
691
+ const relayResult = await submitRelay({
692
+ type: 'transfer',
693
+ pool,
694
+ relayUrl,
695
+ proofArgs: {
696
+ proof: result.args.proof,
697
+ root: result.args.root,
698
+ inputNullifiers: result.args.inputNullifiers,
699
+ outputCommitments: result.args.outputCommitments as [string, string],
700
+ publicAmount: result.args.publicAmount,
701
+ extDataHash: result.args.extDataHash,
702
+ },
703
+ extData: result.extData,
704
+ metadata: {
705
+ amount,
706
+ recipient: 'self',
707
+ inputUtxoCount: selectedUtxos.length,
708
+ outputUtxoCount: outputs.length,
709
+ },
710
+ });
711
+
712
+ return {
713
+ success: relayResult.success,
714
+ transactionHash: relayResult.transactionHash,
715
+ blockNumber: relayResult.blockNumber,
716
+ amount,
717
+ slot: normalizedSlot,
718
+ pool,
719
+ };
720
+ }
package/src/types.ts CHANGED
@@ -377,6 +377,25 @@ export interface SubaccountBalances {
377
377
  usdc: SubaccountAssetBalance;
378
378
  }
379
379
 
380
+ /**
381
+ * Private pool balance summary for a specific asset
382
+ */
383
+ export interface SubaccountPrivateBalanceStatus {
384
+ privateBalance: string;
385
+ privateBalanceWei: string;
386
+ utxoCount: number;
387
+ spentCount: number;
388
+ unspentCount: number;
389
+ }
390
+
391
+ /**
392
+ * Private pool balances for both supported assets
393
+ */
394
+ export interface SubaccountPrivateBalances {
395
+ eth: SubaccountPrivateBalanceStatus;
396
+ usdc: SubaccountPrivateBalanceStatus;
397
+ }
398
+
380
399
  /**
381
400
  * Queue status for a specific asset
382
401
  */
@@ -395,6 +414,7 @@ export interface SubaccountStatusResult {
395
414
  slot: SubaccountSlot;
396
415
  deployed: boolean;
397
416
  balances: SubaccountBalances;
417
+ privateBalances: SubaccountPrivateBalances;
398
418
  queues: {
399
419
  eth: SubaccountQueueStatus;
400
420
  usdc: SubaccountQueueStatus;
@@ -427,6 +447,42 @@ export interface SubaccountWithdrawTypedData {
427
447
  };
428
448
  }
429
449
 
450
+ /**
451
+ * Options for merging a subaccount's private balance back to the main wallet
452
+ */
453
+ export interface SubaccountMergeOptions {
454
+ /** Root private key (VEIL_KEY) */
455
+ rootPrivateKey: `0x${string}`;
456
+ /** Subaccount slot (0-2) */
457
+ slot: number;
458
+ /** Pool to merge in (default: 'eth') */
459
+ pool?: RelayPool;
460
+ /** Optional RPC URL */
461
+ rpcUrl?: string;
462
+ /** Optional relay URL */
463
+ relayUrl?: string;
464
+ /** Progress callback */
465
+ onProgress?: (stage: string, detail?: string) => void;
466
+ }
467
+
468
+ /**
469
+ * Result from merging a subaccount's balance to the main wallet
470
+ */
471
+ export interface SubaccountMergeResult {
472
+ /** Whether the merge was successful */
473
+ success: boolean;
474
+ /** Transaction hash */
475
+ transactionHash: string;
476
+ /** Block number of the transaction */
477
+ blockNumber: string;
478
+ /** Amount merged (human-readable) */
479
+ amount: string;
480
+ /** Subaccount slot that was merged */
481
+ slot: number;
482
+ /** Pool the merge was executed in */
483
+ pool: RelayPool;
484
+ }
485
+
430
486
  /**
431
487
  * Built recovery transaction and signing metadata
432
488
  */