@veil-cash/sdk 0.6.1 → 0.6.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veil-cash/sdk",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "SDK and CLI for interacting with Veil Cash privacy pools - keypair generation, deposits, and status checking",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,12 +1,13 @@
1
1
  ---
2
2
  name: veil
3
- version: 0.6.0
3
+ version: 0.6.2
4
4
  description: >
5
5
  Veil CLI for private ETH and USDC transactions on Base. Use when the user wants
6
6
  to deposit, withdraw, or transfer assets privately, check private balances,
7
7
  manage Veil keypairs, register on-chain, manage deterministic subaccounts
8
- (forwarder deploy, sweep, recover), or build unsigned transaction payloads
9
- for an external signer (e.g. Bankr). All operations target Base (chain ID 8453).
8
+ (forwarder deploy, sweep, merge to main wallet, recover), or build unsigned
9
+ transaction payloads for an external signer (e.g. Bankr). All operations
10
+ target Base (chain ID 8453).
10
11
  author: veildotcash
11
12
  metadata:
12
13
  homepage: https://veil.cash
@@ -35,6 +36,7 @@ triggers:
35
36
  - pattern: withdraw privately
36
37
  - pattern: private transfer
37
38
  - pattern: subaccount
39
+ - pattern: subaccount merge
38
40
  - pattern: forwarder
39
41
  - pattern: stealth deposit
40
42
  ---
@@ -200,6 +202,7 @@ What do you want to do?
200
202
  | Subaccount address | `veil subaccount address --slot 0` |
201
203
  | Deploy forwarder | `veil subaccount deploy --slot 0` |
202
204
  | Sweep forwarder | `veil subaccount sweep --slot 0 --asset eth` |
205
+ | Merge subaccount to main | `veil subaccount merge --slot 0 --pool eth` |
203
206
  | Recover from forwarder | `veil subaccount recover --slot 0 --asset usdc --to 0xAddr --amount 25` |
204
207
 
205
208
  ---
@@ -428,11 +431,13 @@ Subaccounts are deterministic child slots derived from your main `VEIL_KEY`:
428
431
  `root key → slot → child key → child deposit key → forwarder`
429
432
 
430
433
  Base mainnet only. Slots are `0`–`2` (max 3 subaccounts). Deploy and sweep are
431
- relay-backed (no `WALLET_KEY` needed). Recovery submits a direct on-chain
432
- transaction and **requires `WALLET_KEY`** as a gas payer.
434
+ relay-backed (no `WALLET_KEY` needed). Merge transfers the subaccount's private
435
+ pool balance back to the main wallet via a ZK proof (relay-backed, no `WALLET_KEY`
436
+ needed). Recovery submits a direct on-chain transaction and **requires `WALLET_KEY`**
437
+ as a gas payer.
433
438
 
434
- Status reports the forwarder wallet balances and queue state only, not private
435
- pool attribution after queued funds are accepted.
439
+ Status reports the child slot's forwarder wallet balances, private pool
440
+ balances, and queue state.
436
441
 
437
442
  ### Derive and inspect
438
443
 
@@ -440,7 +445,7 @@ pool attribution after queued funds are accepted.
440
445
  veil subaccount derive --slot 0 # Full slot metadata
441
446
  veil subaccount derive --slot 0 --json
442
447
  veil subaccount address --slot 0 # Just the forwarder address
443
- veil subaccount status --slot 0 # Deployment, balances, queue state
448
+ veil subaccount status --slot 0 # Deployment, forwarder balances, private balances, queue state
444
449
  veil subaccount status --slot 0 --json
445
450
  ```
446
451
 
@@ -454,6 +459,18 @@ veil subaccount sweep --slot 0 --asset usdc # Sweep USDC into the pool
454
459
  veil subaccount sweep --slot 0 --asset eth --json
455
460
  ```
456
461
 
462
+ ### Merge subaccount to main wallet (relay-backed)
463
+
464
+ Merge transfers the subaccount's entire private pool balance back to the main
465
+ wallet. It builds a ZK proof transferring child UTXOs to the parent keypair and
466
+ submits via the relay. Only needs `VEIL_KEY`.
467
+
468
+ ```bash
469
+ veil subaccount merge --slot 0 --pool eth
470
+ veil subaccount merge --slot 0 --pool usdc
471
+ veil subaccount merge --slot 0 --pool eth --json
472
+ ```
473
+
457
474
  ### Recover (direct on-chain — requires WALLET_KEY)
458
475
 
459
476
  Recovery is for assets still sitting on the forwarder after refund or rejection.
@@ -470,6 +487,7 @@ Important:
470
487
  - `--asset` is `eth` or `usdc` (case-insensitive in the CLI)
471
488
  - `--slot` is `0`–`2`
472
489
  - Deploy and sweep only need `VEIL_KEY`
490
+ - Merge only needs `VEIL_KEY`
473
491
  - Recover needs both `VEIL_KEY` and `WALLET_KEY`
474
492
 
475
493
  ---
@@ -148,9 +148,11 @@ const priv = await getPrivateBalance({
148
148
  ```typescript
149
149
  import {
150
150
  deriveSubaccountSlot,
151
+ getSubaccountPrivateBalance,
151
152
  getSubaccountStatus,
152
153
  deploySubaccountForwarder,
153
154
  sweepSubaccountForwarder,
155
+ mergeSubaccount,
154
156
  buildSubaccountRecoveryTx,
155
157
  isSubaccountForwarderDeployed,
156
158
  MAX_SUBACCOUNT_SLOTS,
@@ -168,12 +170,20 @@ const deployed = await isSubaccountForwarderDeployed({
168
170
  forwarderAddress: slot.forwarderAddress,
169
171
  });
170
172
 
171
- // Full status (deployment, balances, queue state)
173
+ // Full status (deployment, forwarder balances, private balances, queue state)
172
174
  const status = await getSubaccountStatus({
173
175
  rootPrivateKey: '0xVEIL_KEY',
174
176
  slot: 0,
175
177
  });
176
- // status.deployed, status.balances.eth, status.balances.usdc, status.queues
178
+ // status.deployed, status.balances, status.privateBalances, status.queues
179
+
180
+ // Private pool balance for a single slot + pool
181
+ const privateBalance = await getSubaccountPrivateBalance({
182
+ rootPrivateKey: '0xVEIL_KEY',
183
+ slot: 0,
184
+ pool: 'eth',
185
+ });
186
+ // privateBalance.privateBalance, privateBalance.unspentCount, privateBalance.utxos
177
187
 
178
188
  // Deploy forwarder (relay-backed, no WALLET_KEY needed)
179
189
  const deployResult = await deploySubaccountForwarder({
@@ -188,6 +198,14 @@ const sweepResult = await sweepSubaccountForwarder({
188
198
  asset: 'eth', // 'eth' | 'usdc'
189
199
  });
190
200
 
201
+ // Merge subaccount's private balance back to main wallet (relay-backed)
202
+ const mergeResult = await mergeSubaccount({
203
+ rootPrivateKey: '0xVEIL_KEY',
204
+ slot: 0,
205
+ pool: 'eth', // 'eth' | 'usdc' (default: 'eth')
206
+ });
207
+ // mergeResult.success, mergeResult.transactionHash, mergeResult.amount, mergeResult.slot, mergeResult.pool
208
+
191
209
  // Build recovery transaction (for assets stuck on forwarder)
192
210
  const recovery = await buildSubaccountRecoveryTx({
193
211
  rootPrivateKey: '0xVEIL_KEY',
@@ -256,12 +274,14 @@ veil balance private --json # Private balance as JSON
256
274
  veil subaccount derive --slot 0 # Derive slot metadata
257
275
  veil subaccount derive --slot 0 --json # Derive as JSON
258
276
  veil subaccount address --slot 0 # Print forwarder address
259
- veil subaccount status --slot 0 # Deployment, balances, queue state
277
+ veil subaccount status --slot 0 # Deployment, forwarder balances, private balances, queue state
260
278
  veil subaccount status --slot 0 --json # Status as JSON
261
279
  veil subaccount deploy --slot 0 # Deploy forwarder (relay-backed)
262
280
  veil subaccount deploy --slot 0 --json # Deploy as JSON
263
281
  veil subaccount sweep --slot 0 --asset eth # Sweep ETH into pool (relay-backed)
264
282
  veil subaccount sweep --slot 0 --asset usdc --json # Sweep USDC as JSON
283
+ veil subaccount merge --slot 0 --pool eth # Merge subaccount balance to main wallet
284
+ veil subaccount merge --slot 0 --pool usdc --json # Merge USDC as JSON
265
285
  veil subaccount recover --slot 0 --asset usdc --to 0x... --amount 25 # Recover assets (needs WALLET_KEY)
266
286
  veil subaccount recover --slot 0 --asset eth --to 0x... --amount 0.05 --json
267
287
  ```
@@ -7,13 +7,14 @@ import {
7
7
  getSubaccountStatus,
8
8
  isSubaccountForwarderDeployed,
9
9
  MAX_SUBACCOUNT_SLOTS,
10
+ mergeSubaccount,
10
11
  sweepSubaccountForwarder,
11
12
  } from '../../subaccount.js';
12
13
  import { getConfig } from '../config.js';
13
14
  import { CLIError, ErrorCode, handleCLIError } from '../errors.js';
14
15
  import { printFields, printHeader, printJson, printLine, printList, printSection, txUrl } from '../output.js';
15
16
  import { sendTransaction } from '../wallet.js';
16
- import type { SubaccountAsset } from '../../types.js';
17
+ import type { SubaccountAsset, RelayPool } from '../../types.js';
17
18
 
18
19
  function parseSlotValue(raw: string): number {
19
20
  const normalized = raw.trim();
@@ -51,6 +52,14 @@ function parseAsset(raw: string): SubaccountAsset {
51
52
  return asset;
52
53
  }
53
54
 
55
+ function parsePool(raw: string): RelayPool {
56
+ const pool = raw.toLowerCase();
57
+ if (pool !== 'eth' && pool !== 'usdc') {
58
+ throw new CLIError(ErrorCode.INVALID_AMOUNT, `Unsupported pool: ${raw}. Supported: eth, usdc`);
59
+ }
60
+ return pool;
61
+ }
62
+
54
63
  function printQueueHuman(
55
64
  title: string,
56
65
  queue: {
@@ -81,6 +90,7 @@ Examples:
81
90
  veil subaccount status --slot 0
82
91
  veil subaccount deploy --slot 0
83
92
  veil subaccount sweep --slot 0 --asset eth
93
+ veil subaccount merge --slot 0 --pool eth
84
94
  veil subaccount recover --slot 0 --asset usdc --to 0xRecipientAddress --amount 25
85
95
  veil subaccount address --slot 0
86
96
  `);
@@ -130,7 +140,7 @@ Examples:
130
140
 
131
141
  subaccount
132
142
  .command('status')
133
- .description('Show subaccount deployment, balances, and queue state')
143
+ .description('Show subaccount deployment, forwarder balances, private balances, and queue state')
134
144
  .requiredOption('--slot <n>', 'Subaccount slot', parseSlotValue)
135
145
  .option('--json', 'Output as JSON')
136
146
  .action(async (options) => {
@@ -162,6 +172,18 @@ Examples:
162
172
  { label: 'USDC', value: `${status.balances.usdc.balance} USDC` },
163
173
  ]);
164
174
 
175
+ printSection('Private Pool Balances');
176
+ printFields([
177
+ {
178
+ label: 'ETH',
179
+ value: `${status.privateBalances.eth.privateBalance} ETH (${status.privateBalances.eth.unspentCount} unspent / ${status.privateBalances.eth.spentCount} spent / ${status.privateBalances.eth.utxoCount} total UTXOs)`,
180
+ },
181
+ {
182
+ label: 'USDC',
183
+ value: `${status.privateBalances.usdc.privateBalance} USDC (${status.privateBalances.usdc.unspentCount} unspent / ${status.privateBalances.usdc.spentCount} spent / ${status.privateBalances.usdc.utxoCount} total UTXOs)`,
184
+ },
185
+ ]);
186
+
165
187
  printQueueHuman('ETH Queue', status.queues.eth);
166
188
  printQueueHuman('USDC Queue', status.queues.usdc);
167
189
  printLine();
@@ -255,6 +277,64 @@ Examples:
255
277
  }
256
278
  });
257
279
 
280
+ subaccount
281
+ .command('merge')
282
+ .description('Merge a subaccount\'s private pool balance back to the main wallet')
283
+ .requiredOption('--slot <n>', 'Subaccount slot', parseSlotValue)
284
+ .option('--pool <pool>', 'Pool to merge (eth or usdc)', parsePool, 'eth' as RelayPool)
285
+ .option('--json', 'Output as JSON')
286
+ .action(async (options) => {
287
+ try {
288
+ const rootPrivateKey = getRequiredVeilKey();
289
+ const result = await mergeSubaccount({
290
+ rootPrivateKey,
291
+ slot: options.slot,
292
+ pool: options.pool,
293
+ rpcUrl: process.env.RPC_URL,
294
+ relayUrl: process.env.RELAY_URL,
295
+ onProgress: options.json
296
+ ? undefined
297
+ : (stage, detail) => {
298
+ const msg = detail ? `${stage} ${detail}` : stage;
299
+ process.stderr.write(`\r\x1b[K${msg}`);
300
+ },
301
+ });
302
+
303
+ if (!options.json) {
304
+ process.stderr.write('\r\x1b[K');
305
+ }
306
+
307
+ const output = {
308
+ success: result.success,
309
+ slot: result.slot,
310
+ pool: result.pool,
311
+ amount: result.amount,
312
+ transactionHash: result.transactionHash,
313
+ blockNumber: result.blockNumber,
314
+ };
315
+
316
+ if (options.json) {
317
+ printJson(output);
318
+ return;
319
+ }
320
+
321
+ printHeader('Subaccount Merge Submitted');
322
+ printFields([
323
+ { label: 'Slot', value: result.slot },
324
+ { label: 'Pool', value: result.pool.toUpperCase() },
325
+ { label: 'Amount', value: result.amount },
326
+ { label: 'Transaction', value: txUrl(result.transactionHash) },
327
+ { label: 'Block', value: result.blockNumber },
328
+ ]);
329
+ printLine();
330
+ } catch (error) {
331
+ if (!options.json) {
332
+ process.stderr.write('\r\x1b[K');
333
+ }
334
+ handleCLIError(error);
335
+ }
336
+ });
337
+
258
338
  subaccount
259
339
  .command('recover')
260
340
  .description('Recover assets sitting on the subaccount forwarder with a direct withdraw transaction')
package/src/cli/index.ts CHANGED
@@ -38,7 +38,7 @@ const program = new Command();
38
38
  program
39
39
  .name('veil')
40
40
  .description('CLI for Veil Cash privacy pools on Base')
41
- .version('0.6.1')
41
+ .version('0.6.2')
42
42
  .addHelpText('after', `
43
43
  Getting started:
44
44
  veil init
package/src/index.ts CHANGED
@@ -139,12 +139,14 @@ export {
139
139
  isSubaccountForwarderDeployed,
140
140
  deploySubaccountForwarder,
141
141
  sweepSubaccountForwarder,
142
+ getSubaccountPrivateBalance,
142
143
  getSubaccountStatus,
143
144
  buildSubaccountWithdrawTypedData,
144
145
  signSubaccountWithdraw,
145
146
  isSubaccountWithdrawNonceUsed,
146
147
  findNextSubaccountWithdrawNonce,
147
148
  buildSubaccountRecoveryTx,
149
+ mergeSubaccount,
148
150
  } from './subaccount.js';
149
151
 
150
152
  // Utilities
@@ -197,8 +199,12 @@ export type {
197
199
  SubaccountRelayResult,
198
200
  SubaccountAssetBalance,
199
201
  SubaccountBalances,
202
+ SubaccountPrivateBalanceStatus,
203
+ SubaccountPrivateBalances,
200
204
  SubaccountQueueStatus,
201
205
  SubaccountStatusResult,
202
206
  SubaccountWithdrawTypedData,
203
207
  SubaccountRecoveryResult,
208
+ SubaccountMergeOptions,
209
+ SubaccountMergeResult,
204
210
  } from './types.js';
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
  */