@veil-cash/sdk 0.5.0 → 0.6.1

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.
@@ -0,0 +1,355 @@
1
+ import { Command } from 'commander';
2
+ import { isAddress } from 'viem';
3
+ import {
4
+ buildSubaccountRecoveryTx,
5
+ deploySubaccountForwarder,
6
+ deriveSubaccountSlot,
7
+ getSubaccountStatus,
8
+ isSubaccountForwarderDeployed,
9
+ MAX_SUBACCOUNT_SLOTS,
10
+ sweepSubaccountForwarder,
11
+ } from '../../subaccount.js';
12
+ import { getConfig } from '../config.js';
13
+ import { CLIError, ErrorCode, handleCLIError } from '../errors.js';
14
+ import { printFields, printHeader, printJson, printLine, printList, printSection, txUrl } from '../output.js';
15
+ import { sendTransaction } from '../wallet.js';
16
+ import type { SubaccountAsset } from '../../types.js';
17
+
18
+ function parseSlotValue(raw: string): number {
19
+ const normalized = raw.trim();
20
+ if (!/^\d+$/.test(normalized)) {
21
+ throw new CLIError(ErrorCode.INVALID_SLOT, '--slot must be a non-negative integer');
22
+ }
23
+
24
+ const slot = Number(normalized);
25
+ if (slot >= MAX_SUBACCOUNT_SLOTS) {
26
+ throw new CLIError(
27
+ ErrorCode.INVALID_SLOT,
28
+ `--slot must be 0-${MAX_SUBACCOUNT_SLOTS - 1} (max ${MAX_SUBACCOUNT_SLOTS} subaccounts supported)`,
29
+ );
30
+ }
31
+
32
+ return slot;
33
+ }
34
+
35
+ function getRequiredVeilKey(): `0x${string}` {
36
+ const veilKey = process.env.VEIL_KEY;
37
+ if (!veilKey) {
38
+ throw new CLIError(ErrorCode.VEIL_KEY_MISSING, 'VEIL_KEY required. Set VEIL_KEY env');
39
+ }
40
+ if (!/^0x[a-fA-F0-9]{64}$/.test(veilKey)) {
41
+ throw new CLIError(ErrorCode.VEIL_KEY_MISSING, 'VEIL_KEY must be a 0x-prefixed 32-byte hex string');
42
+ }
43
+ return veilKey as `0x${string}`;
44
+ }
45
+
46
+ function parseAsset(raw: string): SubaccountAsset {
47
+ const asset = raw.toLowerCase();
48
+ if (asset !== 'eth' && asset !== 'usdc') {
49
+ throw new CLIError(ErrorCode.INVALID_AMOUNT, `Unsupported asset: ${raw}. Supported: eth, usdc`);
50
+ }
51
+ return asset;
52
+ }
53
+
54
+ function printQueueHuman(
55
+ title: string,
56
+ queue: {
57
+ queueBalance: string;
58
+ pendingCount: number;
59
+ pendingDeposits: Array<{ nonce: string; amount: string; status: string }>;
60
+ },
61
+ ): void {
62
+ printSection(title);
63
+ printFields([
64
+ { label: 'Queue balance', value: queue.queueBalance },
65
+ { label: 'Pending', value: queue.pendingCount },
66
+ ]);
67
+
68
+ if (queue.pendingDeposits.length > 0) {
69
+ printList(
70
+ queue.pendingDeposits.map((deposit) => `nonce ${deposit.nonce}: ${deposit.amount} (${deposit.status})`),
71
+ );
72
+ }
73
+ }
74
+
75
+ export function createSubaccountCommand(): Command {
76
+ const subaccount = new Command('subaccount')
77
+ .description('Manage Veil subaccounts')
78
+ .addHelpText('after', `
79
+ Examples:
80
+ veil subaccount derive --slot 0
81
+ veil subaccount status --slot 0
82
+ veil subaccount deploy --slot 0
83
+ veil subaccount sweep --slot 0 --asset eth
84
+ veil subaccount recover --slot 0 --asset usdc --to 0xRecipientAddress --amount 25
85
+ veil subaccount address --slot 0
86
+ `);
87
+
88
+ subaccount
89
+ .command('derive')
90
+ .description('Derive subaccount metadata for a slot')
91
+ .requiredOption('--slot <n>', 'Subaccount slot', parseSlotValue)
92
+ .option('--json', 'Output as JSON')
93
+ .action(async (options) => {
94
+ try {
95
+ const rootPrivateKey = getRequiredVeilKey();
96
+ const rpcUrl = process.env.RPC_URL;
97
+ const slot = await deriveSubaccountSlot({
98
+ rootPrivateKey,
99
+ slot: options.slot,
100
+ rpcUrl,
101
+ });
102
+ const deployed = await isSubaccountForwarderDeployed({
103
+ forwarderAddress: slot.forwarderAddress,
104
+ rpcUrl,
105
+ });
106
+
107
+ const output = {
108
+ ...slot,
109
+ deployed,
110
+ };
111
+
112
+ if (options.json) {
113
+ printJson(output);
114
+ return;
115
+ }
116
+
117
+ printHeader(`Subaccount Slot ${slot.slot}`);
118
+ printFields([
119
+ { label: 'Child owner', value: slot.childOwner },
120
+ { label: 'Deposit key', value: slot.childDepositKey },
121
+ { label: 'Salt', value: slot.salt },
122
+ { label: 'Forwarder', value: slot.forwarderAddress },
123
+ { label: 'Deployed', value: deployed },
124
+ ]);
125
+ printLine();
126
+ } catch (error) {
127
+ handleCLIError(error);
128
+ }
129
+ });
130
+
131
+ subaccount
132
+ .command('status')
133
+ .description('Show subaccount deployment, balances, and queue state')
134
+ .requiredOption('--slot <n>', 'Subaccount slot', parseSlotValue)
135
+ .option('--json', 'Output as JSON')
136
+ .action(async (options) => {
137
+ try {
138
+ const rootPrivateKey = getRequiredVeilKey();
139
+ const status = await getSubaccountStatus({
140
+ rootPrivateKey,
141
+ slot: options.slot,
142
+ rpcUrl: process.env.RPC_URL,
143
+ });
144
+
145
+ if (options.json) {
146
+ printJson(status);
147
+ return;
148
+ }
149
+
150
+ printHeader(`Subaccount Slot ${status.slot.slot}`);
151
+ printFields([
152
+ { label: 'Forwarder', value: status.slot.forwarderAddress },
153
+ { label: 'Child owner', value: status.slot.childOwner },
154
+ { label: 'Deposit key', value: status.slot.childDepositKey },
155
+ { label: 'Salt', value: status.slot.salt },
156
+ { label: 'Deployed', value: status.deployed },
157
+ ]);
158
+
159
+ printSection('Forwarder Balances');
160
+ printFields([
161
+ { label: 'ETH', value: `${status.balances.eth.balance} ETH` },
162
+ { label: 'USDC', value: `${status.balances.usdc.balance} USDC` },
163
+ ]);
164
+
165
+ printQueueHuman('ETH Queue', status.queues.eth);
166
+ printQueueHuman('USDC Queue', status.queues.usdc);
167
+ printLine();
168
+ } catch (error) {
169
+ handleCLIError(error);
170
+ }
171
+ });
172
+
173
+ subaccount
174
+ .command('deploy')
175
+ .description('Deploy a subaccount forwarder through the relay')
176
+ .requiredOption('--slot <n>', 'Subaccount slot', parseSlotValue)
177
+ .option('--json', 'Output as JSON')
178
+ .action(async (options) => {
179
+ try {
180
+ const rootPrivateKey = getRequiredVeilKey();
181
+ const result = await deploySubaccountForwarder({
182
+ rootPrivateKey,
183
+ slot: options.slot,
184
+ rpcUrl: process.env.RPC_URL,
185
+ relayUrl: process.env.RELAY_URL,
186
+ });
187
+
188
+ const output = {
189
+ ...result,
190
+ slot: options.slot,
191
+ forwarderAddress: result.slot.forwarderAddress,
192
+ };
193
+
194
+ if (options.json) {
195
+ printJson(output);
196
+ return;
197
+ }
198
+
199
+ printHeader('Subaccount Deploy Submitted');
200
+ printFields([
201
+ { label: 'Slot', value: options.slot },
202
+ { label: 'Forwarder', value: result.slot.forwarderAddress },
203
+ { label: 'Transaction', value: txUrl(result.transactionHash) },
204
+ { label: 'Block', value: result.blockNumber },
205
+ ]);
206
+ printLine();
207
+ } catch (error) {
208
+ handleCLIError(error);
209
+ }
210
+ });
211
+
212
+ subaccount
213
+ .command('sweep')
214
+ .description('Sweep ETH or USDC from a subaccount forwarder through the relay')
215
+ .requiredOption('--slot <n>', 'Subaccount slot', parseSlotValue)
216
+ .requiredOption('--asset <asset>', 'Asset to sweep (eth or usdc)', parseAsset)
217
+ .option('--json', 'Output as JSON')
218
+ .action(async (options) => {
219
+ try {
220
+ const rootPrivateKey = getRequiredVeilKey();
221
+ const slot = await deriveSubaccountSlot({
222
+ rootPrivateKey,
223
+ slot: options.slot,
224
+ rpcUrl: process.env.RPC_URL,
225
+ });
226
+ const result = await sweepSubaccountForwarder({
227
+ forwarderAddress: slot.forwarderAddress,
228
+ asset: options.asset,
229
+ relayUrl: process.env.RELAY_URL,
230
+ });
231
+
232
+ const output = {
233
+ ...result,
234
+ slot: options.slot,
235
+ asset: options.asset,
236
+ forwarderAddress: slot.forwarderAddress,
237
+ };
238
+
239
+ if (options.json) {
240
+ printJson(output);
241
+ return;
242
+ }
243
+
244
+ printHeader('Subaccount Sweep Submitted');
245
+ printFields([
246
+ { label: 'Slot', value: options.slot },
247
+ { label: 'Asset', value: options.asset.toUpperCase() },
248
+ { label: 'Forwarder', value: slot.forwarderAddress },
249
+ { label: 'Transaction', value: txUrl(result.transactionHash) },
250
+ { label: 'Block', value: result.blockNumber },
251
+ ]);
252
+ printLine();
253
+ } catch (error) {
254
+ handleCLIError(error);
255
+ }
256
+ });
257
+
258
+ subaccount
259
+ .command('recover')
260
+ .description('Recover assets sitting on the subaccount forwarder with a direct withdraw transaction')
261
+ .requiredOption('--slot <n>', 'Subaccount slot', parseSlotValue)
262
+ .requiredOption('--asset <asset>', 'Asset to recover (eth or usdc)', parseAsset)
263
+ .requiredOption('--to <address>', 'Recipient address')
264
+ .requiredOption('--amount <value>', 'Amount to recover')
265
+ .option('--json', 'Output as JSON')
266
+ .action(async (options) => {
267
+ try {
268
+ const rootPrivateKey = getRequiredVeilKey();
269
+ if (!isAddress(options.to)) {
270
+ throw new CLIError(ErrorCode.INVALID_ADDRESS, `Invalid recipient address: ${options.to}`);
271
+ }
272
+ if (!process.env.WALLET_KEY) {
273
+ throw new CLIError(
274
+ ErrorCode.WALLET_KEY_MISSING,
275
+ 'WALLET_KEY required for recovery. Recovery submits a transaction on-chain and needs a gas payer.',
276
+ );
277
+ }
278
+ const config = getConfig({});
279
+ const recovery = await buildSubaccountRecoveryTx({
280
+ rootPrivateKey,
281
+ slot: options.slot,
282
+ asset: options.asset,
283
+ to: options.to as `0x${string}`,
284
+ amount: options.amount,
285
+ rpcUrl: process.env.RPC_URL,
286
+ });
287
+ const result = await sendTransaction(config, recovery.transaction);
288
+
289
+ const output = {
290
+ success: result.receipt.status === 'success',
291
+ slot: options.slot,
292
+ asset: recovery.asset,
293
+ amount: recovery.amount,
294
+ amountWei: recovery.amountWei,
295
+ forwarderAddress: recovery.forwarderAddress,
296
+ recipient: recovery.recipient,
297
+ nonce: recovery.nonce,
298
+ deadline: recovery.deadline,
299
+ signature: recovery.signature,
300
+ transactionHash: result.hash,
301
+ blockNumber: result.receipt.blockNumber.toString(),
302
+ };
303
+
304
+ if (options.json) {
305
+ printJson(output);
306
+ return;
307
+ }
308
+
309
+ printHeader('Subaccount Recovery Submitted');
310
+ printFields([
311
+ { label: 'Slot', value: options.slot },
312
+ { label: 'Asset', value: recovery.asset.toUpperCase() },
313
+ { label: 'Amount', value: recovery.amount },
314
+ { label: 'Recipient', value: recovery.recipient },
315
+ { label: 'Forwarder', value: recovery.forwarderAddress },
316
+ { label: 'Nonce', value: recovery.nonce },
317
+ { label: 'Transaction', value: txUrl(result.hash) },
318
+ { label: 'Block', value: result.receipt.blockNumber },
319
+ ]);
320
+ printLine();
321
+ } catch (error) {
322
+ handleCLIError(error);
323
+ }
324
+ });
325
+
326
+ subaccount
327
+ .command('address')
328
+ .description('Print the predicted forwarder address for a subaccount slot')
329
+ .requiredOption('--slot <n>', 'Subaccount slot', parseSlotValue)
330
+ .option('--json', 'Output as JSON')
331
+ .action(async (options) => {
332
+ try {
333
+ const rootPrivateKey = getRequiredVeilKey();
334
+ const slot = await deriveSubaccountSlot({
335
+ rootPrivateKey,
336
+ slot: options.slot,
337
+ rpcUrl: process.env.RPC_URL,
338
+ });
339
+
340
+ if (options.json) {
341
+ printJson({
342
+ slot: options.slot,
343
+ forwarderAddress: slot.forwarderAddress,
344
+ });
345
+ return;
346
+ }
347
+
348
+ printLine(slot.forwarderAddress);
349
+ } catch (error) {
350
+ handleCLIError(error);
351
+ }
352
+ });
353
+
354
+ return subaccount;
355
+ }
package/src/cli/errors.ts CHANGED
@@ -11,6 +11,7 @@ export const ErrorCode = {
11
11
  DEPOSIT_KEY_MISSING: 'DEPOSIT_KEY_MISSING',
12
12
  CONFIG_CONFLICT: 'CONFIG_CONFLICT',
13
13
  INVALID_ADDRESS: 'INVALID_ADDRESS',
14
+ INVALID_SLOT: 'INVALID_SLOT',
14
15
  INVALID_AMOUNT: 'INVALID_AMOUNT',
15
16
  INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE',
16
17
  USER_NOT_REGISTERED: 'USER_NOT_REGISTERED',
@@ -58,6 +59,9 @@ function inferErrorCode(message: string): ErrorCodeType {
58
59
  if (msg.includes('invalid') && msg.includes('address')) {
59
60
  return ErrorCode.INVALID_ADDRESS;
60
61
  }
62
+ if (msg.includes('invalid') && msg.includes('slot')) {
63
+ return ErrorCode.INVALID_SLOT;
64
+ }
61
65
  if (msg.includes('insufficient balance') || msg.includes('not enough')) {
62
66
  return ErrorCode.INSUFFICIENT_BALANCE;
63
67
  }
package/src/cli/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * veil withdraw ETH 0.1 0x... # Withdraw to public address
14
14
  * veil transfer ETH 0.1 0x... # Transfer privately
15
15
  * veil merge ETH 0.5 # Merge UTXOs (self-transfer)
16
+ * veil subaccount status --slot 0 # Check subaccount status
16
17
  */
17
18
 
18
19
  import { Command } from 'commander';
@@ -27,6 +28,7 @@ import { createPrivateBalanceCommand } from './commands/private-balance.js';
27
28
  import { createWithdrawCommand } from './commands/withdraw.js';
28
29
  import { createTransferCommand, createMergeCommand } from './commands/transfer.js';
29
30
  import { createStatusCommand } from './commands/status.js';
31
+ import { createSubaccountCommand } from './commands/subaccount.js';
30
32
 
31
33
  // Load environment variables
32
34
  loadEnv();
@@ -36,13 +38,14 @@ const program = new Command();
36
38
  program
37
39
  .name('veil')
38
40
  .description('CLI for Veil Cash privacy pools on Base')
39
- .version('0.5.0')
41
+ .version('0.6.1')
40
42
  .addHelpText('after', `
41
43
  Getting started:
42
44
  veil init
43
45
  veil register
44
46
  veil deposit ETH 0.1
45
47
  veil balance
48
+ veil subaccount status --slot 0
46
49
  `);
47
50
 
48
51
  // Add commands
@@ -57,6 +60,7 @@ program.addCommand(createWithdrawCommand());
57
60
  program.addCommand(createTransferCommand());
58
61
  program.addCommand(createMergeCommand());
59
62
  program.addCommand(createStatusCommand());
63
+ program.addCommand(createSubaccountCommand());
60
64
 
61
65
  const knownTopLevelCommands = new Set([
62
66
  ...program.commands.map((command) => command.name()),
package/src/cli/wallet.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  import { privateKeyToAccount } from 'viem/accounts';
15
15
  import { base } from 'viem/chains';
16
16
  import type { TransactionData } from '../types.js';
17
- import { ENTRY_ABI, ERC20_ABI } from '../abi.js';
17
+ import { ENTRY_ABI, ERC20_ABI, FORWARDER_ABI } from '../abi.js';
18
18
  import { getAddresses, POOL_CONFIG, ADDRESSES } from '../addresses.js';
19
19
 
20
20
  export interface WalletConfig {
@@ -118,7 +118,7 @@ function decodeCustomError(error: unknown): string | null {
118
118
 
119
119
  if (possibleData && typeof possibleData === 'string' && possibleData.startsWith('0x')) {
120
120
  try {
121
- for (const abi of [ENTRY_ABI] as const) {
121
+ for (const abi of [ENTRY_ABI, FORWARDER_ABI] as const) {
122
122
  try {
123
123
  const decoded = decodeErrorResult({
124
124
  abi,
package/src/index.ts CHANGED
@@ -100,8 +100,10 @@ export type { ProofInput } from './prover.js';
100
100
  // Addresses and config
101
101
  export {
102
102
  ADDRESSES,
103
+ FORWARDER_CONTRACT_VERSION,
103
104
  POOL_CONFIG,
104
105
  getAddresses,
106
+ getForwarderFactoryAddress,
105
107
  getPoolAddress,
106
108
  getQueueAddress,
107
109
  getRelayUrl,
@@ -119,10 +121,32 @@ export {
119
121
  export {
120
122
  ENTRY_ABI,
121
123
  ERC20_ABI,
124
+ FORWARDER_ABI,
125
+ FORWARDER_FACTORY_ABI,
122
126
  QUEUE_ABI,
123
127
  POOL_ABI,
124
128
  } from './abi.js';
125
129
 
130
+ // Subaccount functions
131
+ export {
132
+ MAX_SUBACCOUNT_SLOTS,
133
+ deriveSubaccountChildPrivateKey,
134
+ deriveSubaccountSalt,
135
+ deriveSubaccountChildOwner,
136
+ deriveSubaccountChildDepositKey,
137
+ deriveSubaccountSlot,
138
+ predictSubaccountForwarder,
139
+ isSubaccountForwarderDeployed,
140
+ deploySubaccountForwarder,
141
+ sweepSubaccountForwarder,
142
+ getSubaccountStatus,
143
+ buildSubaccountWithdrawTypedData,
144
+ signSubaccountWithdraw,
145
+ isSubaccountWithdrawNonceUsed,
146
+ findNextSubaccountWithdrawNonce,
147
+ buildSubaccountRecoveryTx,
148
+ } from './subaccount.js';
149
+
126
150
  // Utilities
127
151
  export {
128
152
  poseidonHash,
@@ -166,4 +190,15 @@ export type {
166
190
  WithdrawResult,
167
191
  TransferResult,
168
192
  UtxoSelectionResult,
193
+ SubaccountAsset,
194
+ SubaccountSlot,
195
+ SubaccountDeployRequest,
196
+ SubaccountSweepRequest,
197
+ SubaccountRelayResult,
198
+ SubaccountAssetBalance,
199
+ SubaccountBalances,
200
+ SubaccountQueueStatus,
201
+ SubaccountStatusResult,
202
+ SubaccountWithdrawTypedData,
203
+ SubaccountRecoveryResult,
169
204
  } from './types.js';
package/src/relay.ts CHANGED
@@ -50,6 +50,44 @@ export class RelayError extends Error {
50
50
  }
51
51
  }
52
52
 
53
+ export async function postRelayJson<T>(
54
+ endpoint: string,
55
+ body: unknown,
56
+ relayUrl?: string,
57
+ ): Promise<T> {
58
+ const url = relayUrl || getRelayUrl();
59
+ const response = await fetch(`${url}${endpoint}`, {
60
+ method: 'POST',
61
+ headers: {
62
+ 'Content-Type': 'application/json',
63
+ },
64
+ body: JSON.stringify(body),
65
+ });
66
+
67
+ const text = await response.text();
68
+ let data: unknown;
69
+ try {
70
+ data = JSON.parse(text);
71
+ } catch {
72
+ throw new RelayError(
73
+ `Relay returned non-JSON response (HTTP ${response.status})`,
74
+ response.status,
75
+ );
76
+ }
77
+
78
+ if (!response.ok) {
79
+ const errorData = data as RelayErrorResponse;
80
+ throw new RelayError(
81
+ errorData.error || errorData.message || 'Relay request failed',
82
+ response.status,
83
+ errorData.retryAfter,
84
+ errorData.network,
85
+ );
86
+ }
87
+
88
+ return data as T;
89
+ }
90
+
53
91
  /**
54
92
  * Submit a withdrawal or transfer to the relay service
55
93
  *
@@ -107,34 +145,17 @@ export async function submitRelay(options: SubmitRelayOptions): Promise<RelayRes
107
145
  }
108
146
 
109
147
  const relayUrl = customRelayUrl || getRelayUrl();
110
- const endpoint = `${relayUrl}/relay/${pool}`;
111
-
112
- const response = await fetch(endpoint, {
113
- method: 'POST',
114
- headers: {
115
- 'Content-Type': 'application/json',
116
- },
117
- body: JSON.stringify({
148
+ const endpoint = `/relay/${pool}`;
149
+ return postRelayJson<RelayResponse>(
150
+ endpoint,
151
+ {
118
152
  type,
119
153
  proofArgs,
120
154
  extData,
121
155
  metadata,
122
- }),
123
- });
124
-
125
- const data = await response.json();
126
-
127
- if (!response.ok) {
128
- const errorData = data as RelayErrorResponse;
129
- throw new RelayError(
130
- errorData.error || errorData.message || 'Relay request failed',
131
- response.status,
132
- errorData.retryAfter,
133
- errorData.network
134
- );
135
- }
136
-
137
- return data as RelayResponse;
156
+ },
157
+ relayUrl,
158
+ );
138
159
  }
139
160
 
140
161
  /**