@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.
@@ -4203,6 +4203,14 @@ var QUEUE_ABI = [
4203
4203
  outputs: [{ name: "count", type: "uint256" }],
4204
4204
  stateMutability: "view",
4205
4205
  type: "function"
4206
+ },
4207
+ // Get remaining daily free deposits for an address (V3+)
4208
+ {
4209
+ inputs: [{ name: "_depositor", type: "address" }],
4210
+ name: "getDailyFreeRemaining",
4211
+ outputs: [{ name: "remaining", type: "uint256" }],
4212
+ stateMutability: "view",
4213
+ type: "function"
4206
4214
  }
4207
4215
  ];
4208
4216
  var POOL_ABI = [
@@ -5784,174 +5792,6 @@ function createRegisterCommand() {
5784
5792
  });
5785
5793
  return register;
5786
5794
  }
5787
- var MINIMUM_NET = {
5788
- ETH: 0.01,
5789
- USDC: 10
5790
- };
5791
- async function getGrossAmount(netWei, rpcUrl) {
5792
- const publicClient = viem.createPublicClient({
5793
- chain: chains.base,
5794
- transport: viem.http(rpcUrl)
5795
- });
5796
- const grossWei = await publicClient.readContract({
5797
- address: getAddresses().entry,
5798
- abi: ENTRY_ABI,
5799
- functionName: "getDepositAmountWithFee",
5800
- args: [netWei]
5801
- });
5802
- return { grossWei, feeWei: grossWei - netWei };
5803
- }
5804
- var SUPPORTED_ASSETS = ["ETH", "USDC"];
5805
- function createDepositCommand() {
5806
- const deposit = new Command("deposit").description("Deposit ETH or USDC into Veil").argument("<asset>", "Asset to deposit (ETH or USDC)").argument("<amount>", "Amount to deposit \u2014 this is what arrives in your Veil balance").option("--unsigned", "Output unsigned transaction payload instead of sending").option("--json", "Output as JSON").addHelpText("after", `
5807
- The amount you specify is the net amount that lands in your Veil balance.
5808
- The 0.3% protocol fee is automatically added on top.
5809
-
5810
- Examples:
5811
- veil deposit ETH 0.1 # deposits 0.1 ETH (sends ~0.1003 ETH)
5812
- veil deposit USDC 100 # deposits 100 USDC (sends ~100.30 USDC)
5813
- veil deposit ETH 0.1 --unsigned
5814
- veil deposit ETH 0.1 --json
5815
- `).action(async (asset, amount, options) => {
5816
- try {
5817
- const assetUpper = asset.toUpperCase();
5818
- if (!SUPPORTED_ASSETS.includes(assetUpper)) {
5819
- throw new CLIError(ErrorCode.INVALID_AMOUNT, `Unsupported asset: ${asset}. Supported: ${SUPPORTED_ASSETS.join(", ")}`);
5820
- }
5821
- const amountNum = parseFloat(amount);
5822
- const minimumNet = MINIMUM_NET[assetUpper];
5823
- if (amountNum < minimumNet) {
5824
- throw new CLIError(
5825
- ErrorCode.INVALID_AMOUNT,
5826
- `Minimum deposit is ${minimumNet} ${assetUpper}.`
5827
- );
5828
- }
5829
- const rpcUrl = process.env.RPC_URL;
5830
- const poolConfig = POOL_CONFIG[assetUpper.toLowerCase()];
5831
- const netWei = assetUpper === "ETH" ? viem.parseEther(amount) : viem.parseUnits(amount, poolConfig.decimals);
5832
- const progress = createProgressReporter();
5833
- progress("Calculating fee...");
5834
- const { grossWei, feeWei } = await getGrossAmount(netWei, rpcUrl);
5835
- const grossStr = assetUpper === "ETH" ? viem.formatEther(grossWei) : viem.formatUnits(grossWei, poolConfig.decimals);
5836
- const feeStr = assetUpper === "ETH" ? viem.formatEther(feeWei) : viem.formatUnits(feeWei, poolConfig.decimals);
5837
- const depositKey = process.env.DEPOSIT_KEY;
5838
- if (!depositKey) {
5839
- throw new CLIError(ErrorCode.DEPOSIT_KEY_MISSING, 'DEPOSIT_KEY not set. Run "veil init" first.');
5840
- }
5841
- progress("Building transaction...");
5842
- let tx;
5843
- let approveTx = null;
5844
- if (assetUpper === "USDC") {
5845
- approveTx = buildApproveUSDCTx({ amount: grossStr });
5846
- tx = buildDepositUSDCTx({ depositKey, amount: grossStr });
5847
- } else {
5848
- tx = buildDepositETHTx({ depositKey, amount: grossStr });
5849
- }
5850
- if (options.unsigned) {
5851
- clearProgress();
5852
- const payloads = [];
5853
- if (approveTx) {
5854
- payloads.push({
5855
- step: "approve",
5856
- to: approveTx.to,
5857
- data: approveTx.data,
5858
- value: "0",
5859
- chainId: 8453
5860
- });
5861
- }
5862
- payloads.push({
5863
- step: "deposit",
5864
- to: tx.to,
5865
- data: tx.data,
5866
- value: tx.value ? tx.value.toString() : "0",
5867
- chainId: 8453
5868
- });
5869
- printJson(payloads.length === 1 ? payloads[0] : payloads);
5870
- return;
5871
- }
5872
- const config = getConfig(options);
5873
- const address = getAddress(config.privateKey);
5874
- if (assetUpper === "ETH") {
5875
- progress("Checking balance...");
5876
- const balance = await getBalance(address, config.rpcUrl);
5877
- if (balance < grossWei) {
5878
- clearProgress();
5879
- throw new CLIError(
5880
- ErrorCode.INSUFFICIENT_BALANCE,
5881
- `Insufficient ETH balance. Have: ${viem.formatEther(balance)} ETH, Need: ${grossStr} ETH (${amount} + fee)`
5882
- );
5883
- }
5884
- }
5885
- if (approveTx) {
5886
- progress(`Approving ${assetUpper}...`);
5887
- const approvalResult = await sendTransaction(config, approveTx);
5888
- if (assetUpper === "USDC") {
5889
- const publicClient = viem.createPublicClient({
5890
- chain: chains.base,
5891
- transport: viem.http(config.rpcUrl)
5892
- });
5893
- const addresses = getAddresses();
5894
- let allowance = await publicClient.readContract({
5895
- address: getAddresses().usdcToken,
5896
- abi: ERC20_ABI,
5897
- functionName: "allowance",
5898
- args: [address, addresses.entry]
5899
- });
5900
- for (let confirmations = 2; allowance < grossWei && confirmations <= 3; confirmations++) {
5901
- await publicClient.waitForTransactionReceipt({
5902
- hash: approvalResult.hash,
5903
- confirmations
5904
- });
5905
- allowance = await publicClient.readContract({
5906
- address: addresses.usdcToken,
5907
- abi: ERC20_ABI,
5908
- functionName: "allowance",
5909
- args: [address, addresses.entry]
5910
- });
5911
- }
5912
- if (allowance < grossWei) {
5913
- throw new CLIError(
5914
- ErrorCode.CONTRACT_ERROR,
5915
- `USDC approval is not yet visible on RPC after confirmation. Allowance ${allowance.toString()} < required ${grossWei.toString()}.`
5916
- );
5917
- }
5918
- }
5919
- }
5920
- progress("Sending deposit transaction...");
5921
- const result = await sendTransaction(config, tx);
5922
- progress("Confirming...");
5923
- clearProgress();
5924
- const output = {
5925
- success: result.receipt.status === "success",
5926
- hash: result.hash,
5927
- asset: assetUpper,
5928
- amount,
5929
- fee: feeStr,
5930
- totalSent: grossStr,
5931
- blockNumber: result.receipt.blockNumber.toString()
5932
- };
5933
- if (options.json) {
5934
- printJson(output);
5935
- return;
5936
- }
5937
- printHeader("Deposit Submitted");
5938
- printFields([
5939
- { label: "Asset", value: assetUpper },
5940
- { label: "Amount", value: `${amount} ${assetUpper}` },
5941
- { label: "Fee", value: `${feeStr} ${assetUpper} (0.3%)` },
5942
- { label: "Total sent", value: `${grossStr} ${assetUpper}` },
5943
- { label: "From", value: address },
5944
- { label: "Transaction", value: txUrl(result.hash) },
5945
- { label: "Block", value: result.receipt.blockNumber }
5946
- ]);
5947
- printLine();
5948
- } catch (error) {
5949
- clearProgress();
5950
- handleCLIError(error);
5951
- }
5952
- });
5953
- return deposit;
5954
- }
5955
5795
 
5956
5796
  // src/utxo.ts
5957
5797
  var Utxo = class _Utxo {
@@ -6084,6 +5924,25 @@ async function getQueueBalance(options) {
6084
5924
  pendingCount: pendingDeposits.length
6085
5925
  };
6086
5926
  }
5927
+ async function getDailyFreeRemaining(options) {
5928
+ const { address, pool = "eth", rpcUrl } = options;
5929
+ const queueAddress = getQueueAddress(pool);
5930
+ const publicClient = viem.createPublicClient({
5931
+ chain: chains.base,
5932
+ transport: viem.http(rpcUrl)
5933
+ });
5934
+ try {
5935
+ const remaining = await publicClient.readContract({
5936
+ address: queueAddress,
5937
+ abi: QUEUE_ABI,
5938
+ functionName: "getDailyFreeRemaining",
5939
+ args: [address]
5940
+ });
5941
+ return Number(remaining);
5942
+ } catch {
5943
+ return 0;
5944
+ }
5945
+ }
6087
5946
  async function getPrivateBalance(options) {
6088
5947
  const { keypair, pool = "eth", rpcUrl, onProgress } = options;
6089
5948
  const poolAddress = getPoolAddress(pool);
@@ -6136,46 +5995,246 @@ async function getPrivateBalance(options) {
6136
5995
  if (utxo.amount > 0n) {
6137
5996
  decryptedUtxos.push({ utxo, index: i });
6138
5997
  }
6139
- } catch {
6140
- }
6141
- }
6142
- onProgress?.("Found UTXOs", `${decryptedUtxos.length} belonging to you`);
6143
- const utxoInfos = [];
6144
- let totalBalance = 0n;
6145
- let spentCount = 0;
6146
- let unspentCount = 0;
6147
- for (let i = 0; i < decryptedUtxos.length; i++) {
6148
- const { utxo, index } = decryptedUtxos[i];
6149
- onProgress?.("Checking spent status", `UTXO ${i + 1}/${decryptedUtxos.length}`);
6150
- const nullifier = utxo.getNullifier();
6151
- const nullifierHex = toFixedHex(nullifier);
6152
- const isSpent = await publicClient.readContract({
6153
- address: poolAddress,
6154
- abi: POOL_ABI,
6155
- functionName: "isSpent",
6156
- args: [nullifierHex]
6157
- });
6158
- utxoInfos.push({
6159
- index,
6160
- amount: viem.formatUnits(utxo.amount, poolConfig.decimals),
6161
- amountWei: utxo.amount.toString(),
6162
- isSpent
6163
- });
6164
- if (isSpent) {
6165
- spentCount++;
6166
- } else {
6167
- unspentCount++;
6168
- totalBalance += utxo.amount;
5998
+ } catch {
5999
+ }
6000
+ }
6001
+ onProgress?.("Found UTXOs", `${decryptedUtxos.length} belonging to you`);
6002
+ const utxoInfos = [];
6003
+ let totalBalance = 0n;
6004
+ let spentCount = 0;
6005
+ let unspentCount = 0;
6006
+ for (let i = 0; i < decryptedUtxos.length; i++) {
6007
+ const { utxo, index } = decryptedUtxos[i];
6008
+ onProgress?.("Checking spent status", `UTXO ${i + 1}/${decryptedUtxos.length}`);
6009
+ const nullifier = utxo.getNullifier();
6010
+ const nullifierHex = toFixedHex(nullifier);
6011
+ const isSpent = await publicClient.readContract({
6012
+ address: poolAddress,
6013
+ abi: POOL_ABI,
6014
+ functionName: "isSpent",
6015
+ args: [nullifierHex]
6016
+ });
6017
+ utxoInfos.push({
6018
+ index,
6019
+ amount: viem.formatUnits(utxo.amount, poolConfig.decimals),
6020
+ amountWei: utxo.amount.toString(),
6021
+ isSpent
6022
+ });
6023
+ if (isSpent) {
6024
+ spentCount++;
6025
+ } else {
6026
+ unspentCount++;
6027
+ totalBalance += utxo.amount;
6028
+ }
6029
+ }
6030
+ return {
6031
+ privateBalance: viem.formatUnits(totalBalance, poolConfig.decimals),
6032
+ privateBalanceWei: totalBalance.toString(),
6033
+ utxoCount: decryptedUtxos.length,
6034
+ spentCount,
6035
+ unspentCount,
6036
+ utxos: utxoInfos
6037
+ };
6038
+ }
6039
+ var MINIMUM_NET = {
6040
+ ETH: 0.01,
6041
+ USDC: 10
6042
+ };
6043
+ async function getGrossAmount(netWei, depositor, pool, rpcUrl) {
6044
+ const freeRemaining = await getDailyFreeRemaining({ address: depositor, pool, rpcUrl });
6045
+ if (freeRemaining > 0) {
6046
+ return { grossWei: netWei, feeWei: 0n, dailyFreeUsed: true, dailyFreeRemaining: freeRemaining - 1 };
6047
+ }
6048
+ const publicClient = viem.createPublicClient({
6049
+ chain: chains.base,
6050
+ transport: viem.http(rpcUrl)
6051
+ });
6052
+ const grossWei = await publicClient.readContract({
6053
+ address: getAddresses().entry,
6054
+ abi: ENTRY_ABI,
6055
+ functionName: "getDepositAmountWithFee",
6056
+ args: [netWei]
6057
+ });
6058
+ return { grossWei, feeWei: grossWei - netWei, dailyFreeUsed: false, dailyFreeRemaining: 0 };
6059
+ }
6060
+ var SUPPORTED_ASSETS = ["ETH", "USDC"];
6061
+ function createDepositCommand() {
6062
+ const deposit = new Command("deposit").description("Deposit ETH or USDC into Veil").argument("<asset>", "Asset to deposit (ETH or USDC)").argument("<amount>", "Amount to deposit \u2014 this is what arrives in your Veil balance").option("--address <address>", "Signer address (required in --unsigned mode unless SIGNER_ADDRESS or WALLET_KEY is set)").option("--unsigned", "Output unsigned transaction payload instead of sending").option("--json", "Output as JSON").addHelpText("after", `
6063
+ The amount you specify is the net amount that lands in your Veil balance.
6064
+ A 0.3% protocol fee is normally added on top, but each address gets
6065
+ free daily deposits (fee waived). The CLI checks automatically.
6066
+
6067
+ Examples:
6068
+ veil deposit ETH 0.1 # deposits 0.1 ETH (free or ~0.1003 ETH)
6069
+ veil deposit USDC 100 # deposits 100 USDC (free or ~100.30 USDC)
6070
+ veil deposit ETH 0.1 --unsigned --address 0x...
6071
+ SIGNER_ADDRESS=0x... veil deposit ETH 0.1 --unsigned
6072
+ veil deposit ETH 0.1 --json
6073
+ `).action(async (asset, amount, options) => {
6074
+ try {
6075
+ const assetUpper = asset.toUpperCase();
6076
+ if (!SUPPORTED_ASSETS.includes(assetUpper)) {
6077
+ throw new CLIError(ErrorCode.INVALID_AMOUNT, `Unsupported asset: ${asset}. Supported: ${SUPPORTED_ASSETS.join(", ")}`);
6078
+ }
6079
+ const amountNum = parseFloat(amount);
6080
+ const minimumNet = MINIMUM_NET[assetUpper];
6081
+ if (amountNum < minimumNet) {
6082
+ throw new CLIError(
6083
+ ErrorCode.INVALID_AMOUNT,
6084
+ `Minimum deposit is ${minimumNet} ${assetUpper}.`
6085
+ );
6086
+ }
6087
+ const rpcUrl = process.env.RPC_URL;
6088
+ const pool = assetUpper.toLowerCase();
6089
+ const poolConfig = POOL_CONFIG[pool];
6090
+ const netWei = assetUpper === "ETH" ? viem.parseEther(amount) : viem.parseUnits(amount, poolConfig.decimals);
6091
+ const progress = createProgressReporter();
6092
+ let config = null;
6093
+ let address;
6094
+ let feeRpcUrl = rpcUrl;
6095
+ if (options.unsigned) {
6096
+ const resolved = resolveAddress({ address: options.address }, { required: true });
6097
+ if (!resolved) {
6098
+ throw new CLIError(
6099
+ ErrorCode.WALLET_KEY_MISSING,
6100
+ "Must provide --address, set SIGNER_ADDRESS, or set WALLET_KEY env."
6101
+ );
6102
+ }
6103
+ address = resolved.address;
6104
+ } else {
6105
+ config = getConfig(options);
6106
+ address = getAddress(config.privateKey);
6107
+ feeRpcUrl = config.rpcUrl;
6108
+ }
6109
+ progress("Checking deposit fee...");
6110
+ const { grossWei, feeWei, dailyFreeUsed, dailyFreeRemaining } = await getGrossAmount(
6111
+ netWei,
6112
+ address,
6113
+ pool,
6114
+ feeRpcUrl
6115
+ );
6116
+ const grossStr = assetUpper === "ETH" ? viem.formatEther(grossWei) : viem.formatUnits(grossWei, poolConfig.decimals);
6117
+ const feeStr = assetUpper === "ETH" ? viem.formatEther(feeWei) : viem.formatUnits(feeWei, poolConfig.decimals);
6118
+ const depositKey = process.env.DEPOSIT_KEY;
6119
+ if (!depositKey) {
6120
+ throw new CLIError(ErrorCode.DEPOSIT_KEY_MISSING, 'DEPOSIT_KEY not set. Run "veil init" first.');
6121
+ }
6122
+ progress("Building transaction...");
6123
+ let tx;
6124
+ let approveTx = null;
6125
+ if (assetUpper === "USDC") {
6126
+ approveTx = buildApproveUSDCTx({ amount: grossStr });
6127
+ tx = buildDepositUSDCTx({ depositKey, amount: grossStr });
6128
+ } else {
6129
+ tx = buildDepositETHTx({ depositKey, amount: grossStr });
6130
+ }
6131
+ if (options.unsigned) {
6132
+ clearProgress();
6133
+ const payloads = [];
6134
+ if (approveTx) {
6135
+ payloads.push({
6136
+ step: "approve",
6137
+ to: approveTx.to,
6138
+ data: approveTx.data,
6139
+ value: "0",
6140
+ chainId: 8453
6141
+ });
6142
+ }
6143
+ payloads.push({
6144
+ step: "deposit",
6145
+ to: tx.to,
6146
+ data: tx.data,
6147
+ value: tx.value ? tx.value.toString() : "0",
6148
+ chainId: 8453
6149
+ });
6150
+ printJson(payloads.length === 1 ? payloads[0] : payloads);
6151
+ return;
6152
+ }
6153
+ if (!config) {
6154
+ throw new CLIError(ErrorCode.WALLET_KEY_MISSING, "WALLET_KEY env var required. Set it before running this command.");
6155
+ }
6156
+ if (assetUpper === "ETH") {
6157
+ progress("Checking balance...");
6158
+ const balance = await getBalance(address, config.rpcUrl);
6159
+ if (balance < grossWei) {
6160
+ clearProgress();
6161
+ throw new CLIError(
6162
+ ErrorCode.INSUFFICIENT_BALANCE,
6163
+ `Insufficient ETH balance. Have: ${viem.formatEther(balance)} ETH, Need: ${grossStr} ETH (${amount} + fee)`
6164
+ );
6165
+ }
6166
+ }
6167
+ if (approveTx) {
6168
+ progress(`Approving ${assetUpper}...`);
6169
+ const approvalResult = await sendTransaction(config, approveTx);
6170
+ if (assetUpper === "USDC") {
6171
+ const publicClient = viem.createPublicClient({
6172
+ chain: chains.base,
6173
+ transport: viem.http(config.rpcUrl)
6174
+ });
6175
+ const addresses = getAddresses();
6176
+ let allowance = await publicClient.readContract({
6177
+ address: getAddresses().usdcToken,
6178
+ abi: ERC20_ABI,
6179
+ functionName: "allowance",
6180
+ args: [address, addresses.entry]
6181
+ });
6182
+ for (let confirmations = 2; allowance < grossWei && confirmations <= 3; confirmations++) {
6183
+ await publicClient.waitForTransactionReceipt({
6184
+ hash: approvalResult.hash,
6185
+ confirmations
6186
+ });
6187
+ allowance = await publicClient.readContract({
6188
+ address: addresses.usdcToken,
6189
+ abi: ERC20_ABI,
6190
+ functionName: "allowance",
6191
+ args: [address, addresses.entry]
6192
+ });
6193
+ }
6194
+ if (allowance < grossWei) {
6195
+ throw new CLIError(
6196
+ ErrorCode.CONTRACT_ERROR,
6197
+ `USDC approval is not yet visible on RPC after confirmation. Allowance ${allowance.toString()} < required ${grossWei.toString()}.`
6198
+ );
6199
+ }
6200
+ }
6201
+ }
6202
+ progress("Sending deposit transaction...");
6203
+ const result = await sendTransaction(config, tx);
6204
+ progress("Confirming...");
6205
+ clearProgress();
6206
+ const output = {
6207
+ success: result.receipt.status === "success",
6208
+ hash: result.hash,
6209
+ asset: assetUpper,
6210
+ amount,
6211
+ fee: feeStr,
6212
+ dailyFreeUsed,
6213
+ totalSent: grossStr,
6214
+ blockNumber: result.receipt.blockNumber.toString()
6215
+ };
6216
+ if (options.json) {
6217
+ printJson(output);
6218
+ return;
6219
+ }
6220
+ const feeLabel = dailyFreeUsed ? `0 ${assetUpper} (free \u2014 ${dailyFreeRemaining} remaining today)` : `${feeStr} ${assetUpper} (0.3%)`;
6221
+ printHeader("Deposit Submitted");
6222
+ printFields([
6223
+ { label: "Asset", value: assetUpper },
6224
+ { label: "Amount", value: `${amount} ${assetUpper}` },
6225
+ { label: "Fee", value: feeLabel },
6226
+ { label: "Total sent", value: `${grossStr} ${assetUpper}` },
6227
+ { label: "From", value: address },
6228
+ { label: "Transaction", value: txUrl(result.hash) },
6229
+ { label: "Block", value: result.receipt.blockNumber }
6230
+ ]);
6231
+ printLine();
6232
+ } catch (error) {
6233
+ clearProgress();
6234
+ handleCLIError(error);
6169
6235
  }
6170
- }
6171
- return {
6172
- privateBalance: viem.formatUnits(totalBalance, poolConfig.decimals),
6173
- privateBalanceWei: totalBalance.toString(),
6174
- utxoCount: decryptedUtxos.length,
6175
- spentCount,
6176
- unspentCount,
6177
- utxos: utxoInfos
6178
- };
6236
+ });
6237
+ return deposit;
6179
6238
  }
6180
6239
 
6181
6240
  // src/cli/commands/private-balance.ts
@@ -6682,7 +6741,16 @@ async function postRelayJson(endpoint, body, relayUrl) {
6682
6741
  },
6683
6742
  body: JSON.stringify(body)
6684
6743
  });
6685
- const data = await response.json();
6744
+ const text = await response.text();
6745
+ let data;
6746
+ try {
6747
+ data = JSON.parse(text);
6748
+ } catch {
6749
+ throw new RelayError(
6750
+ `Relay returned non-JSON response (HTTP ${response.status})`,
6751
+ response.status
6752
+ );
6753
+ }
6686
6754
  if (!response.ok) {
6687
6755
  const errorData = data;
6688
6756
  throw new RelayError(
@@ -7589,11 +7657,12 @@ function deriveSubaccountChildDepositKey(childPrivateKey) {
7589
7657
  }
7590
7658
  async function predictSubaccountForwarder(options) {
7591
7659
  const publicClient = createBaseClient(options.rpcUrl);
7660
+ const depositKeyBytes = options.childDepositKey.startsWith("0x") ? options.childDepositKey : `0x${options.childDepositKey}`;
7592
7661
  return publicClient.readContract({
7593
7662
  abi: FORWARDER_FACTORY_ABI,
7594
7663
  address: getForwarderFactoryAddress(),
7595
7664
  functionName: "computeAddress",
7596
- args: [options.salt, options.childDepositKey, options.childOwner]
7665
+ args: [options.salt, depositKeyBytes, options.childOwner]
7597
7666
  });
7598
7667
  }
7599
7668
  async function deriveSubaccountSlot(options) {
@@ -7630,7 +7699,7 @@ async function deploySubaccountForwarder(options) {
7630
7699
  slot: options.slot,
7631
7700
  rpcUrl: options.rpcUrl
7632
7701
  });
7633
- return postRelayJson(
7702
+ const result = await postRelayJson(
7634
7703
  "/stealth/deploy",
7635
7704
  {
7636
7705
  salt: slot.salt,
@@ -7640,6 +7709,7 @@ async function deploySubaccountForwarder(options) {
7640
7709
  },
7641
7710
  options.relayUrl
7642
7711
  );
7712
+ return { ...result, slot };
7643
7713
  }
7644
7714
  async function sweepSubaccountForwarder(options) {
7645
7715
  const asset = normalizeAsset(options.asset);
@@ -7664,11 +7734,32 @@ function toQueueStatus(asset, result) {
7664
7734
  pendingDeposits: result.pendingDeposits
7665
7735
  };
7666
7736
  }
7737
+ function toPrivateBalanceStatus(result) {
7738
+ return {
7739
+ privateBalance: result.privateBalance,
7740
+ privateBalanceWei: result.privateBalanceWei,
7741
+ utxoCount: result.utxoCount,
7742
+ spentCount: result.spentCount,
7743
+ unspentCount: result.unspentCount
7744
+ };
7745
+ }
7746
+ async function getSubaccountPrivateBalance(options) {
7747
+ const normalizedSlot = normalizeSlot(options.slot);
7748
+ assertPrivateKey(options.rootPrivateKey, "rootPrivateKey");
7749
+ const childPrivateKey = deriveSubaccountChildPrivateKey(options.rootPrivateKey, normalizedSlot);
7750
+ const childKeypair = new Keypair(childPrivateKey);
7751
+ return getPrivateBalance({
7752
+ keypair: childKeypair,
7753
+ pool: options.pool,
7754
+ rpcUrl: options.rpcUrl,
7755
+ onProgress: options.onProgress
7756
+ });
7757
+ }
7667
7758
  async function getSubaccountStatus(options) {
7668
7759
  const slot = await deriveSubaccountSlot(options);
7669
7760
  const publicClient = createBaseClient(options.rpcUrl);
7670
7761
  const addresses = getAddresses();
7671
- const [deployed, ethWei, usdcWei, ethQueue, usdcQueue] = await Promise.all([
7762
+ const [deployed, ethWei, usdcWei, ethQueue, usdcQueue, ethPrivate, usdcPrivate] = await Promise.all([
7672
7763
  isSubaccountForwarderDeployed({
7673
7764
  forwarderAddress: slot.forwarderAddress,
7674
7765
  rpcUrl: options.rpcUrl
@@ -7689,6 +7780,18 @@ async function getSubaccountStatus(options) {
7689
7780
  address: slot.forwarderAddress,
7690
7781
  pool: "usdc",
7691
7782
  rpcUrl: options.rpcUrl
7783
+ }),
7784
+ getSubaccountPrivateBalance({
7785
+ rootPrivateKey: options.rootPrivateKey,
7786
+ slot: options.slot,
7787
+ pool: "eth",
7788
+ rpcUrl: options.rpcUrl
7789
+ }),
7790
+ getSubaccountPrivateBalance({
7791
+ rootPrivateKey: options.rootPrivateKey,
7792
+ slot: options.slot,
7793
+ pool: "usdc",
7794
+ rpcUrl: options.rpcUrl
7692
7795
  })
7693
7796
  ]);
7694
7797
  return {
@@ -7704,6 +7807,10 @@ async function getSubaccountStatus(options) {
7704
7807
  balanceWei: usdcWei.toString()
7705
7808
  }
7706
7809
  },
7810
+ privateBalances: {
7811
+ eth: toPrivateBalanceStatus(ethPrivate),
7812
+ usdc: toPrivateBalanceStatus(usdcPrivate)
7813
+ },
7707
7814
  queues: {
7708
7815
  eth: toQueueStatus("eth", ethQueue),
7709
7816
  usdc: toQueueStatus("usdc", usdcQueue)
@@ -7850,6 +7957,137 @@ async function buildSubaccountRecoveryTx(options) {
7850
7957
  signature
7851
7958
  };
7852
7959
  }
7960
+ async function mergeSubaccount(options) {
7961
+ const {
7962
+ rootPrivateKey,
7963
+ slot,
7964
+ pool = "eth",
7965
+ rpcUrl,
7966
+ relayUrl,
7967
+ onProgress
7968
+ } = options;
7969
+ const normalizedSlot = normalizeSlot(slot);
7970
+ assertPrivateKey(rootPrivateKey, "rootPrivateKey");
7971
+ const poolConfig = POOL_CONFIG[pool];
7972
+ const poolAddress = getPoolAddress(pool);
7973
+ const childPrivateKey = deriveSubaccountChildPrivateKey(rootPrivateKey, normalizedSlot);
7974
+ const childKeypair = new Keypair(childPrivateKey);
7975
+ const parentKeypair = new Keypair(rootPrivateKey);
7976
+ onProgress?.("Fetching subaccount balance...");
7977
+ const balanceResult = await getPrivateBalance({
7978
+ keypair: childKeypair,
7979
+ pool,
7980
+ rpcUrl,
7981
+ onProgress
7982
+ });
7983
+ const unspentUtxoInfos = balanceResult.utxos.filter((u) => !u.isSpent);
7984
+ if (unspentUtxoInfos.length === 0) {
7985
+ throw new Error("Subaccount has no unspent UTXOs to merge");
7986
+ }
7987
+ if (unspentUtxoInfos.length > 16) {
7988
+ throw new Error(
7989
+ `Subaccount has ${unspentUtxoInfos.length} unspent UTXOs which exceeds the 16-input circuit limit. Consolidate UTXOs on the subaccount first before merging.`
7990
+ );
7991
+ }
7992
+ onProgress?.("Preparing UTXOs...");
7993
+ const publicClient = viem.createPublicClient({
7994
+ chain: chains.base,
7995
+ transport: viem.http(rpcUrl)
7996
+ });
7997
+ const utxos = [];
7998
+ for (const utxoInfo of unspentUtxoInfos) {
7999
+ const encryptedOutputs = await publicClient.readContract({
8000
+ address: poolAddress,
8001
+ abi: POOL_ABI,
8002
+ functionName: "getEncryptedOutputs",
8003
+ args: [BigInt(utxoInfo.index), BigInt(utxoInfo.index + 1)]
8004
+ });
8005
+ if (encryptedOutputs.length > 0) {
8006
+ try {
8007
+ const utxo = Utxo.decrypt(encryptedOutputs[0], childKeypair);
8008
+ utxo.index = utxoInfo.index;
8009
+ utxos.push(utxo);
8010
+ } catch {
8011
+ }
8012
+ }
8013
+ }
8014
+ if (utxos.length === 0) {
8015
+ throw new Error("Failed to decrypt subaccount UTXOs");
8016
+ }
8017
+ onProgress?.("Selecting UTXOs...");
8018
+ const amount = balanceResult.privateBalance;
8019
+ const { selectedUtxos, changeAmount } = selectUtxosForWithdraw(
8020
+ utxos,
8021
+ amount,
8022
+ poolConfig.decimals
8023
+ );
8024
+ const outputs = [];
8025
+ const mergeWei = viem.parseUnits(amount, poolConfig.decimals);
8026
+ outputs.push(new Utxo({ amount: mergeWei, keypair: parentKeypair }));
8027
+ if (changeAmount > 0n) {
8028
+ outputs.push(new Utxo({ amount: changeAmount, keypair: parentKeypair }));
8029
+ }
8030
+ onProgress?.("Fetching commitments...");
8031
+ const nextIndex = await publicClient.readContract({
8032
+ address: poolAddress,
8033
+ abi: POOL_ABI,
8034
+ functionName: "nextIndex"
8035
+ });
8036
+ const BATCH_SIZE = 5e3;
8037
+ const commitments = [];
8038
+ const totalBatches = Math.ceil(nextIndex / BATCH_SIZE);
8039
+ for (let start = 0; start < nextIndex; start += BATCH_SIZE) {
8040
+ const end = Math.min(start + BATCH_SIZE, nextIndex);
8041
+ const batchNum = Math.floor(start / BATCH_SIZE) + 1;
8042
+ onProgress?.("Fetching commitments", `batch ${batchNum}/${totalBatches}`);
8043
+ const batch = await publicClient.readContract({
8044
+ address: poolAddress,
8045
+ abi: POOL_ABI,
8046
+ functionName: "getCommitments",
8047
+ args: [BigInt(start), BigInt(end)]
8048
+ });
8049
+ commitments.push(...batch.map((c) => c.toString()));
8050
+ }
8051
+ onProgress?.("Building ZK proof...");
8052
+ const result = await prepareTransaction({
8053
+ commitments,
8054
+ inputs: selectedUtxos,
8055
+ outputs,
8056
+ fee: 0,
8057
+ recipient: "0x0000000000000000000000000000000000000000",
8058
+ relayer: "0x0000000000000000000000000000000000000000",
8059
+ onProgress
8060
+ });
8061
+ onProgress?.("Submitting to relay...");
8062
+ const relayResult = await submitRelay({
8063
+ type: "transfer",
8064
+ pool,
8065
+ relayUrl,
8066
+ proofArgs: {
8067
+ proof: result.args.proof,
8068
+ root: result.args.root,
8069
+ inputNullifiers: result.args.inputNullifiers,
8070
+ outputCommitments: result.args.outputCommitments,
8071
+ publicAmount: result.args.publicAmount,
8072
+ extDataHash: result.args.extDataHash
8073
+ },
8074
+ extData: result.extData,
8075
+ metadata: {
8076
+ amount,
8077
+ recipient: "self",
8078
+ inputUtxoCount: selectedUtxos.length,
8079
+ outputUtxoCount: outputs.length
8080
+ }
8081
+ });
8082
+ return {
8083
+ success: relayResult.success,
8084
+ transactionHash: relayResult.transactionHash,
8085
+ blockNumber: relayResult.blockNumber,
8086
+ amount,
8087
+ slot: normalizedSlot,
8088
+ pool
8089
+ };
8090
+ }
7853
8091
 
7854
8092
  // src/cli/commands/subaccount.ts
7855
8093
  function parseSlotValue(raw) {
@@ -7883,6 +8121,13 @@ function parseAsset(raw) {
7883
8121
  }
7884
8122
  return asset;
7885
8123
  }
8124
+ function parsePool(raw) {
8125
+ const pool = raw.toLowerCase();
8126
+ if (pool !== "eth" && pool !== "usdc") {
8127
+ throw new CLIError(ErrorCode.INVALID_AMOUNT, `Unsupported pool: ${raw}. Supported: eth, usdc`);
8128
+ }
8129
+ return pool;
8130
+ }
7886
8131
  function printQueueHuman(title, queue) {
7887
8132
  printSection(title);
7888
8133
  printFields([
@@ -7902,6 +8147,7 @@ Examples:
7902
8147
  veil subaccount status --slot 0
7903
8148
  veil subaccount deploy --slot 0
7904
8149
  veil subaccount sweep --slot 0 --asset eth
8150
+ veil subaccount merge --slot 0 --pool eth
7905
8151
  veil subaccount recover --slot 0 --asset usdc --to 0xRecipientAddress --amount 25
7906
8152
  veil subaccount address --slot 0
7907
8153
  `);
@@ -7939,7 +8185,7 @@ Examples:
7939
8185
  handleCLIError(error);
7940
8186
  }
7941
8187
  });
7942
- subaccount.command("status").description("Show subaccount deployment, balances, and queue state").requiredOption("--slot <n>", "Subaccount slot", parseSlotValue).option("--json", "Output as JSON").action(async (options) => {
8188
+ subaccount.command("status").description("Show subaccount deployment, forwarder balances, private balances, and queue state").requiredOption("--slot <n>", "Subaccount slot", parseSlotValue).option("--json", "Output as JSON").action(async (options) => {
7943
8189
  try {
7944
8190
  const rootPrivateKey = getRequiredVeilKey();
7945
8191
  const status = await getSubaccountStatus({
@@ -7964,6 +8210,17 @@ Examples:
7964
8210
  { label: "ETH", value: `${status.balances.eth.balance} ETH` },
7965
8211
  { label: "USDC", value: `${status.balances.usdc.balance} USDC` }
7966
8212
  ]);
8213
+ printSection("Private Pool Balances");
8214
+ printFields([
8215
+ {
8216
+ label: "ETH",
8217
+ value: `${status.privateBalances.eth.privateBalance} ETH (${status.privateBalances.eth.unspentCount} unspent / ${status.privateBalances.eth.spentCount} spent / ${status.privateBalances.eth.utxoCount} total UTXOs)`
8218
+ },
8219
+ {
8220
+ label: "USDC",
8221
+ value: `${status.privateBalances.usdc.privateBalance} USDC (${status.privateBalances.usdc.unspentCount} unspent / ${status.privateBalances.usdc.spentCount} spent / ${status.privateBalances.usdc.utxoCount} total UTXOs)`
8222
+ }
8223
+ ]);
7967
8224
  printQueueHuman("ETH Queue", status.queues.eth);
7968
8225
  printQueueHuman("USDC Queue", status.queues.usdc);
7969
8226
  printLine();
@@ -7974,11 +8231,6 @@ Examples:
7974
8231
  subaccount.command("deploy").description("Deploy a subaccount forwarder through the relay").requiredOption("--slot <n>", "Subaccount slot", parseSlotValue).option("--json", "Output as JSON").action(async (options) => {
7975
8232
  try {
7976
8233
  const rootPrivateKey = getRequiredVeilKey();
7977
- const slot = await deriveSubaccountSlot({
7978
- rootPrivateKey,
7979
- slot: options.slot,
7980
- rpcUrl: process.env.RPC_URL
7981
- });
7982
8234
  const result = await deploySubaccountForwarder({
7983
8235
  rootPrivateKey,
7984
8236
  slot: options.slot,
@@ -7988,7 +8240,7 @@ Examples:
7988
8240
  const output = {
7989
8241
  ...result,
7990
8242
  slot: options.slot,
7991
- forwarderAddress: slot.forwarderAddress
8243
+ forwarderAddress: result.slot.forwarderAddress
7992
8244
  };
7993
8245
  if (options.json) {
7994
8246
  printJson(output);
@@ -7997,7 +8249,7 @@ Examples:
7997
8249
  printHeader("Subaccount Deploy Submitted");
7998
8250
  printFields([
7999
8251
  { label: "Slot", value: options.slot },
8000
- { label: "Forwarder", value: slot.forwarderAddress },
8252
+ { label: "Forwarder", value: result.slot.forwarderAddress },
8001
8253
  { label: "Transaction", value: txUrl(result.transactionHash) },
8002
8254
  { label: "Block", value: result.blockNumber }
8003
8255
  ]);
@@ -8042,12 +8294,63 @@ Examples:
8042
8294
  handleCLIError(error);
8043
8295
  }
8044
8296
  });
8297
+ subaccount.command("merge").description("Merge a subaccount's private pool balance back to the main wallet").requiredOption("--slot <n>", "Subaccount slot", parseSlotValue).option("--pool <pool>", "Pool to merge (eth or usdc)", parsePool, "eth").option("--json", "Output as JSON").action(async (options) => {
8298
+ try {
8299
+ const rootPrivateKey = getRequiredVeilKey();
8300
+ const result = await mergeSubaccount({
8301
+ rootPrivateKey,
8302
+ slot: options.slot,
8303
+ pool: options.pool,
8304
+ rpcUrl: process.env.RPC_URL,
8305
+ relayUrl: process.env.RELAY_URL,
8306
+ onProgress: options.json ? void 0 : (stage, detail) => {
8307
+ const msg = detail ? `${stage} ${detail}` : stage;
8308
+ process.stderr.write(`\r\x1B[K${msg}`);
8309
+ }
8310
+ });
8311
+ if (!options.json) {
8312
+ process.stderr.write("\r\x1B[K");
8313
+ }
8314
+ const output = {
8315
+ success: result.success,
8316
+ slot: result.slot,
8317
+ pool: result.pool,
8318
+ amount: result.amount,
8319
+ transactionHash: result.transactionHash,
8320
+ blockNumber: result.blockNumber
8321
+ };
8322
+ if (options.json) {
8323
+ printJson(output);
8324
+ return;
8325
+ }
8326
+ printHeader("Subaccount Merge Submitted");
8327
+ printFields([
8328
+ { label: "Slot", value: result.slot },
8329
+ { label: "Pool", value: result.pool.toUpperCase() },
8330
+ { label: "Amount", value: result.amount },
8331
+ { label: "Transaction", value: txUrl(result.transactionHash) },
8332
+ { label: "Block", value: result.blockNumber }
8333
+ ]);
8334
+ printLine();
8335
+ } catch (error) {
8336
+ if (!options.json) {
8337
+ process.stderr.write("\r\x1B[K");
8338
+ }
8339
+ handleCLIError(error);
8340
+ }
8341
+ });
8045
8342
  subaccount.command("recover").description("Recover assets sitting on the subaccount forwarder with a direct withdraw transaction").requiredOption("--slot <n>", "Subaccount slot", parseSlotValue).requiredOption("--asset <asset>", "Asset to recover (eth or usdc)", parseAsset).requiredOption("--to <address>", "Recipient address").requiredOption("--amount <value>", "Amount to recover").option("--json", "Output as JSON").action(async (options) => {
8046
8343
  try {
8047
8344
  const rootPrivateKey = getRequiredVeilKey();
8048
8345
  if (!viem.isAddress(options.to)) {
8049
8346
  throw new CLIError(ErrorCode.INVALID_ADDRESS, `Invalid recipient address: ${options.to}`);
8050
8347
  }
8348
+ if (!process.env.WALLET_KEY) {
8349
+ throw new CLIError(
8350
+ ErrorCode.WALLET_KEY_MISSING,
8351
+ "WALLET_KEY required for recovery. Recovery submits a transaction on-chain and needs a gas payer."
8352
+ );
8353
+ }
8051
8354
  const config = getConfig({});
8052
8355
  const recovery = await buildSubaccountRecoveryTx({
8053
8356
  rootPrivateKey,
@@ -8118,7 +8421,7 @@ Examples:
8118
8421
  // src/cli/index.ts
8119
8422
  loadEnv();
8120
8423
  var program2 = new Command();
8121
- program2.name("veil").description("CLI for Veil Cash privacy pools on Base").version("0.6.0").addHelpText("after", `
8424
+ program2.name("veil").description("CLI for Veil Cash privacy pools on Base").version("0.6.2").addHelpText("after", `
8122
8425
  Getting started:
8123
8426
  veil init
8124
8427
  veil register