@veil-cash/sdk 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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,35 @@ 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 data = await response.json();
68
+
69
+ if (!response.ok) {
70
+ const errorData = data as RelayErrorResponse;
71
+ throw new RelayError(
72
+ errorData.error || errorData.message || 'Relay request failed',
73
+ response.status,
74
+ errorData.retryAfter,
75
+ errorData.network,
76
+ );
77
+ }
78
+
79
+ return data as T;
80
+ }
81
+
53
82
  /**
54
83
  * Submit a withdrawal or transfer to the relay service
55
84
  *
@@ -107,34 +136,17 @@ export async function submitRelay(options: SubmitRelayOptions): Promise<RelayRes
107
136
  }
108
137
 
109
138
  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({
139
+ const endpoint = `/relay/${pool}`;
140
+ return postRelayJson<RelayResponse>(
141
+ endpoint,
142
+ {
118
143
  type,
119
144
  proofArgs,
120
145
  extData,
121
146
  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;
147
+ },
148
+ relayUrl,
149
+ );
138
150
  }
139
151
 
140
152
  /**
@@ -0,0 +1,476 @@
1
+ import {
2
+ createPublicClient,
3
+ encodeFunctionData,
4
+ encodePacked,
5
+ formatEther,
6
+ formatUnits,
7
+ http,
8
+ isAddress,
9
+ keccak256,
10
+ parseEther,
11
+ parseUnits,
12
+ } from 'viem';
13
+ import { privateKeyToAccount, privateKeyToAddress } from 'viem/accounts';
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';
18
+ import { Keypair } from './keypair.js';
19
+ import { postRelayJson } from './relay.js';
20
+ import type {
21
+ SubaccountAsset,
22
+ SubaccountDeployRequest,
23
+ SubaccountQueueStatus,
24
+ SubaccountRecoveryResult,
25
+ SubaccountRelayResult,
26
+ SubaccountSlot,
27
+ SubaccountStatusResult,
28
+ SubaccountSweepRequest,
29
+ SubaccountWithdrawTypedData,
30
+ } from './types.js';
31
+
32
+ const SUBACCOUNT_CHILD_DOMAIN = 'veil-sua-child';
33
+ const SUBACCOUNT_SALT_DOMAIN = 'veil-sua-salt';
34
+ const ETH_ADDRESS = '0x0000000000000000000000000000000000000000' as const;
35
+ const DEFAULT_WITHDRAW_DEADLINE_SECONDS = 3600n;
36
+ const DEFAULT_MAX_NONCE_SCAN = 100n;
37
+ export const MAX_SUBACCOUNT_SLOTS = 3;
38
+
39
+ function createBaseClient(rpcUrl?: string) {
40
+ return createPublicClient({
41
+ chain: base,
42
+ transport: http(rpcUrl),
43
+ });
44
+ }
45
+
46
+ function assertPrivateKey(value: string, label: string): asserts value is `0x${string}` {
47
+ if (!/^0x[a-fA-F0-9]{64}$/.test(value)) {
48
+ throw new Error(`${label} must be a 0x-prefixed 32-byte hex string`);
49
+ }
50
+ }
51
+
52
+ function normalizeSlot(slot: number): number {
53
+ if (!Number.isInteger(slot) || slot < 0) {
54
+ throw new Error('slot must be a non-negative integer');
55
+ }
56
+ if (slot >= MAX_SUBACCOUNT_SLOTS) {
57
+ throw new Error(`slot must be less than ${MAX_SUBACCOUNT_SLOTS} (supported slots: 0-${MAX_SUBACCOUNT_SLOTS - 1})`);
58
+ }
59
+ return slot;
60
+ }
61
+
62
+ function normalizeAsset(asset: string): SubaccountAsset {
63
+ if (asset !== 'eth' && asset !== 'usdc') {
64
+ throw new Error('asset must be "eth" or "usdc"');
65
+ }
66
+ return asset;
67
+ }
68
+
69
+ function normalizeNonce(value: bigint | number): bigint {
70
+ const nonce = typeof value === 'bigint' ? value : BigInt(value);
71
+ if (nonce < 0n) {
72
+ throw new Error('nonce must be non-negative');
73
+ }
74
+ return nonce;
75
+ }
76
+
77
+ function normalizeDeadline(deadline?: bigint | number): bigint {
78
+ const nextDeadline =
79
+ deadline === undefined
80
+ ? BigInt(Math.floor(Date.now() / 1000)) + DEFAULT_WITHDRAW_DEADLINE_SECONDS
81
+ : typeof deadline === 'bigint'
82
+ ? deadline
83
+ : BigInt(deadline);
84
+
85
+ if (nextDeadline <= 0n) {
86
+ throw new Error('deadline must be greater than 0');
87
+ }
88
+
89
+ return nextDeadline;
90
+ }
91
+
92
+ export function deriveSubaccountChildPrivateKey(
93
+ rootPrivateKey: string,
94
+ slot: number,
95
+ ): `0x${string}` {
96
+ assertPrivateKey(rootPrivateKey, 'rootPrivateKey');
97
+ const normalizedSlot = normalizeSlot(slot);
98
+ return keccak256(
99
+ encodePacked(
100
+ ['bytes32', 'string', 'uint256'],
101
+ [rootPrivateKey, SUBACCOUNT_CHILD_DOMAIN, BigInt(normalizedSlot)],
102
+ ),
103
+ );
104
+ }
105
+
106
+ export function deriveSubaccountSalt(
107
+ rootPrivateKey: string,
108
+ slot: number,
109
+ ): `0x${string}` {
110
+ assertPrivateKey(rootPrivateKey, 'rootPrivateKey');
111
+ const normalizedSlot = normalizeSlot(slot);
112
+ return keccak256(
113
+ encodePacked(
114
+ ['bytes32', 'string', 'uint256'],
115
+ [rootPrivateKey, SUBACCOUNT_SALT_DOMAIN, BigInt(normalizedSlot)],
116
+ ),
117
+ );
118
+ }
119
+
120
+ export function deriveSubaccountChildOwner(childPrivateKey: string): `0x${string}` {
121
+ assertPrivateKey(childPrivateKey, 'childPrivateKey');
122
+ return privateKeyToAddress(childPrivateKey);
123
+ }
124
+
125
+ export function deriveSubaccountChildDepositKey(childPrivateKey: string): string {
126
+ assertPrivateKey(childPrivateKey, 'childPrivateKey');
127
+ return new Keypair(childPrivateKey).depositKey();
128
+ }
129
+
130
+ export async function predictSubaccountForwarder(options: {
131
+ salt: `0x${string}`;
132
+ childDepositKey: string;
133
+ childOwner: `0x${string}`;
134
+ rpcUrl?: string;
135
+ }): Promise<`0x${string}`> {
136
+ const publicClient = createBaseClient(options.rpcUrl);
137
+ return publicClient.readContract({
138
+ abi: FORWARDER_FACTORY_ABI,
139
+ address: getForwarderFactoryAddress(),
140
+ functionName: 'computeAddress',
141
+ args: [options.salt, options.childDepositKey as `0x${string}`, options.childOwner],
142
+ }) as Promise<`0x${string}`>;
143
+ }
144
+
145
+ export async function deriveSubaccountSlot(options: {
146
+ rootPrivateKey: `0x${string}`;
147
+ slot: number;
148
+ rpcUrl?: string;
149
+ }): Promise<SubaccountSlot> {
150
+ const normalizedSlot = normalizeSlot(options.slot);
151
+ const childPrivateKey = deriveSubaccountChildPrivateKey(options.rootPrivateKey, normalizedSlot);
152
+ const salt = deriveSubaccountSalt(options.rootPrivateKey, normalizedSlot);
153
+ const childOwner = deriveSubaccountChildOwner(childPrivateKey);
154
+ const childDepositKey = deriveSubaccountChildDepositKey(childPrivateKey);
155
+ const forwarderAddress = await predictSubaccountForwarder({
156
+ salt,
157
+ childDepositKey,
158
+ childOwner,
159
+ rpcUrl: options.rpcUrl,
160
+ });
161
+
162
+ return {
163
+ slot: normalizedSlot,
164
+ childOwner,
165
+ childDepositKey,
166
+ salt,
167
+ forwarderAddress,
168
+ };
169
+ }
170
+
171
+ export async function isSubaccountForwarderDeployed(options: {
172
+ forwarderAddress: `0x${string}`;
173
+ rpcUrl?: string;
174
+ }): Promise<boolean> {
175
+ if (!isAddress(options.forwarderAddress)) {
176
+ throw new Error('forwarderAddress must be a valid Ethereum address');
177
+ }
178
+
179
+ const publicClient = createBaseClient(options.rpcUrl);
180
+ const code = await publicClient.getCode({ address: options.forwarderAddress });
181
+ return !!code && code !== '0x';
182
+ }
183
+
184
+ export async function deploySubaccountForwarder(
185
+ options: SubaccountDeployRequest,
186
+ ): Promise<SubaccountRelayResult> {
187
+ const slot = await deriveSubaccountSlot({
188
+ rootPrivateKey: options.rootPrivateKey,
189
+ slot: options.slot,
190
+ rpcUrl: options.rpcUrl,
191
+ });
192
+
193
+ return postRelayJson<SubaccountRelayResult>(
194
+ '/stealth/deploy',
195
+ {
196
+ salt: slot.salt,
197
+ childDepositKey: slot.childDepositKey,
198
+ childOwner: slot.childOwner,
199
+ expectedForwarder: slot.forwarderAddress,
200
+ },
201
+ options.relayUrl,
202
+ );
203
+ }
204
+
205
+ export async function sweepSubaccountForwarder(
206
+ options: SubaccountSweepRequest,
207
+ ): Promise<SubaccountRelayResult> {
208
+ const asset = normalizeAsset(options.asset);
209
+ if (!isAddress(options.forwarderAddress)) {
210
+ throw new Error('forwarderAddress must be a valid Ethereum address');
211
+ }
212
+
213
+ return postRelayJson<SubaccountRelayResult>(
214
+ '/stealth/sweep',
215
+ {
216
+ forwarder: options.forwarderAddress,
217
+ asset,
218
+ },
219
+ options.relayUrl,
220
+ );
221
+ }
222
+
223
+ function toQueueStatus(
224
+ asset: SubaccountAsset,
225
+ result: Awaited<ReturnType<typeof getQueueBalance>>,
226
+ ): SubaccountQueueStatus {
227
+ return {
228
+ asset,
229
+ queueBalance: result.queueBalance,
230
+ queueBalanceWei: result.queueBalanceWei,
231
+ pendingCount: result.pendingCount,
232
+ pendingDeposits: result.pendingDeposits,
233
+ };
234
+ }
235
+
236
+ export async function getSubaccountStatus(options: {
237
+ rootPrivateKey: `0x${string}`;
238
+ slot: number;
239
+ rpcUrl?: string;
240
+ }): Promise<SubaccountStatusResult> {
241
+ const slot = await deriveSubaccountSlot(options);
242
+ const publicClient = createBaseClient(options.rpcUrl);
243
+ const addresses = getAddresses();
244
+
245
+ const [deployed, ethWei, usdcWei, ethQueue, usdcQueue] = await Promise.all([
246
+ isSubaccountForwarderDeployed({
247
+ forwarderAddress: slot.forwarderAddress,
248
+ rpcUrl: options.rpcUrl,
249
+ }),
250
+ publicClient.getBalance({ address: slot.forwarderAddress }),
251
+ publicClient.readContract({
252
+ address: addresses.usdcToken,
253
+ abi: ERC20_ABI,
254
+ functionName: 'balanceOf',
255
+ args: [slot.forwarderAddress],
256
+ }) as Promise<bigint>,
257
+ getQueueBalance({
258
+ address: slot.forwarderAddress,
259
+ pool: 'eth',
260
+ rpcUrl: options.rpcUrl,
261
+ }),
262
+ getQueueBalance({
263
+ address: slot.forwarderAddress,
264
+ pool: 'usdc',
265
+ rpcUrl: options.rpcUrl,
266
+ }),
267
+ ]);
268
+
269
+ return {
270
+ slot,
271
+ deployed,
272
+ balances: {
273
+ eth: {
274
+ balance: formatEther(ethWei),
275
+ balanceWei: ethWei.toString(),
276
+ },
277
+ usdc: {
278
+ balance: formatUnits(usdcWei, 6),
279
+ balanceWei: usdcWei.toString(),
280
+ },
281
+ },
282
+ queues: {
283
+ eth: toQueueStatus('eth', ethQueue),
284
+ usdc: toQueueStatus('usdc', usdcQueue),
285
+ },
286
+ };
287
+ }
288
+
289
+ const WITHDRAW_TYPES: SubaccountWithdrawTypedData['types'] = {
290
+ Withdraw: [
291
+ { name: 'token', type: 'address' },
292
+ { name: 'to', type: 'address' },
293
+ { name: 'amount', type: 'uint256' },
294
+ { name: 'nonce', type: 'uint256' },
295
+ { name: 'deadline', type: 'uint256' },
296
+ ],
297
+ };
298
+
299
+ export function buildSubaccountWithdrawTypedData(options: {
300
+ forwarderAddress: `0x${string}`;
301
+ token: `0x${string}`;
302
+ to: `0x${string}`;
303
+ amount: bigint;
304
+ nonce: bigint;
305
+ deadline: bigint;
306
+ }): SubaccountWithdrawTypedData {
307
+ if (!isAddress(options.forwarderAddress)) {
308
+ throw new Error('forwarderAddress must be a valid Ethereum address');
309
+ }
310
+ if (!isAddress(options.token)) {
311
+ throw new Error('token must be a valid Ethereum address');
312
+ }
313
+ if (!isAddress(options.to)) {
314
+ throw new Error('to must be a valid Ethereum address');
315
+ }
316
+
317
+ return {
318
+ domain: {
319
+ name: 'VeilForwarder',
320
+ version: FORWARDER_CONTRACT_VERSION,
321
+ chainId: getAddresses().chainId,
322
+ verifyingContract: options.forwarderAddress,
323
+ },
324
+ types: WITHDRAW_TYPES,
325
+ primaryType: 'Withdraw',
326
+ message: {
327
+ token: options.token,
328
+ to: options.to,
329
+ amount: options.amount,
330
+ nonce: options.nonce,
331
+ deadline: options.deadline,
332
+ },
333
+ };
334
+ }
335
+
336
+ export async function signSubaccountWithdraw(options: {
337
+ childPrivateKey: `0x${string}`;
338
+ typedData: SubaccountWithdrawTypedData;
339
+ }): Promise<`0x${string}`> {
340
+ assertPrivateKey(options.childPrivateKey, 'childPrivateKey');
341
+ const account = privateKeyToAccount(options.childPrivateKey);
342
+ return account.signTypedData({
343
+ domain: options.typedData.domain,
344
+ types: options.typedData.types,
345
+ primaryType: options.typedData.primaryType,
346
+ message: options.typedData.message,
347
+ });
348
+ }
349
+
350
+ export async function isSubaccountWithdrawNonceUsed(options: {
351
+ forwarderAddress: `0x${string}`;
352
+ nonce: bigint | number;
353
+ rpcUrl?: string;
354
+ }): Promise<boolean> {
355
+ if (!isAddress(options.forwarderAddress)) {
356
+ throw new Error('forwarderAddress must be a valid Ethereum address');
357
+ }
358
+
359
+ const publicClient = createBaseClient(options.rpcUrl);
360
+ try {
361
+ return await publicClient.readContract({
362
+ abi: FORWARDER_ABI,
363
+ address: options.forwarderAddress,
364
+ functionName: 'usedNonces',
365
+ args: [normalizeNonce(options.nonce)],
366
+ }) as boolean;
367
+ } catch (error) {
368
+ if (String(error).includes('returned no data')) {
369
+ throw new Error('Subaccount forwarder is not deployed');
370
+ }
371
+ throw error;
372
+ }
373
+ }
374
+
375
+ export async function findNextSubaccountWithdrawNonce(options: {
376
+ forwarderAddress: `0x${string}`;
377
+ startNonce?: bigint | number;
378
+ maxScan?: bigint | number;
379
+ rpcUrl?: string;
380
+ }): Promise<bigint> {
381
+ const startNonce = normalizeNonce(options.startNonce ?? 0n);
382
+ const maxScan = normalizeNonce(options.maxScan ?? DEFAULT_MAX_NONCE_SCAN);
383
+ const limit = startNonce + maxScan;
384
+ let nonce = startNonce;
385
+
386
+ while (
387
+ await isSubaccountWithdrawNonceUsed({
388
+ forwarderAddress: options.forwarderAddress,
389
+ nonce,
390
+ rpcUrl: options.rpcUrl,
391
+ })
392
+ ) {
393
+ nonce += 1n;
394
+ if (nonce > limit) {
395
+ throw new Error('Unable to find an unused withdraw nonce within the scan limit');
396
+ }
397
+ }
398
+
399
+ return nonce;
400
+ }
401
+
402
+ export async function buildSubaccountRecoveryTx(options: {
403
+ rootPrivateKey: `0x${string}`;
404
+ slot: number;
405
+ asset: SubaccountAsset;
406
+ to: `0x${string}`;
407
+ amount: string;
408
+ nonce?: bigint | number;
409
+ deadline?: bigint | number;
410
+ rpcUrl?: string;
411
+ }): Promise<SubaccountRecoveryResult> {
412
+ if (!isAddress(options.to)) {
413
+ throw new Error('to must be a valid Ethereum address');
414
+ }
415
+
416
+ const asset = normalizeAsset(options.asset);
417
+ const slot = await deriveSubaccountSlot({
418
+ rootPrivateKey: options.rootPrivateKey,
419
+ slot: options.slot,
420
+ rpcUrl: options.rpcUrl,
421
+ });
422
+ const deployed = await isSubaccountForwarderDeployed({
423
+ forwarderAddress: slot.forwarderAddress,
424
+ rpcUrl: options.rpcUrl,
425
+ });
426
+ if (!deployed) {
427
+ throw new Error('Subaccount forwarder is not deployed');
428
+ }
429
+ const childPrivateKey = deriveSubaccountChildPrivateKey(options.rootPrivateKey, options.slot);
430
+ const tokenAddress =
431
+ asset === 'eth' ? ETH_ADDRESS : getAddresses().usdcToken;
432
+ const amountWei =
433
+ asset === 'eth'
434
+ ? parseEther(options.amount)
435
+ : parseUnits(options.amount, 6);
436
+ const nonce =
437
+ options.nonce === undefined
438
+ ? await findNextSubaccountWithdrawNonce({
439
+ forwarderAddress: slot.forwarderAddress,
440
+ rpcUrl: options.rpcUrl,
441
+ })
442
+ : normalizeNonce(options.nonce);
443
+ const deadline = normalizeDeadline(options.deadline);
444
+ const typedData = buildSubaccountWithdrawTypedData({
445
+ forwarderAddress: slot.forwarderAddress,
446
+ token: tokenAddress,
447
+ to: options.to,
448
+ amount: amountWei,
449
+ nonce,
450
+ deadline,
451
+ });
452
+ const signature = await signSubaccountWithdraw({
453
+ childPrivateKey,
454
+ typedData,
455
+ });
456
+
457
+ return {
458
+ transaction: {
459
+ to: slot.forwarderAddress,
460
+ data: encodeFunctionData({
461
+ abi: FORWARDER_ABI,
462
+ functionName: 'withdraw',
463
+ args: [tokenAddress, options.to, amountWei, nonce, deadline, signature],
464
+ }),
465
+ },
466
+ forwarderAddress: slot.forwarderAddress,
467
+ asset,
468
+ amount: options.amount,
469
+ amountWei: amountWei.toString(),
470
+ nonce: nonce.toString(),
471
+ deadline: deadline.toString(),
472
+ recipient: options.to,
473
+ tokenAddress,
474
+ signature,
475
+ };
476
+ }