@veil-cash/sdk 0.4.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.
@@ -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
+ }
package/src/types.ts CHANGED
@@ -27,6 +27,7 @@ export interface NetworkAddresses {
27
27
  usdcPool: `0x${string}`;
28
28
  usdcQueue: `0x${string}`;
29
29
  usdcToken: `0x${string}`;
30
+ forwarderFactory: `0x${string}`;
30
31
  chainId: number;
31
32
  relayUrl: string;
32
33
  }
@@ -308,3 +309,136 @@ export interface UtxoSelectionResult {
308
309
  /** Change amount to return to sender (wei) */
309
310
  changeAmount: bigint;
310
311
  }
312
+
313
+ // =============================================================================
314
+ // Subaccount Types
315
+ // =============================================================================
316
+
317
+ /**
318
+ * Supported subaccount assets
319
+ */
320
+ export type SubaccountAsset = 'eth' | 'usdc';
321
+
322
+ /**
323
+ * Deterministically derived subaccount slot metadata
324
+ */
325
+ export interface SubaccountSlot {
326
+ slot: number;
327
+ childOwner: `0x${string}`;
328
+ childDepositKey: string;
329
+ salt: `0x${string}`;
330
+ forwarderAddress: `0x${string}`;
331
+ }
332
+
333
+ /**
334
+ * Relay-backed forwarder deploy request
335
+ */
336
+ export interface SubaccountDeployRequest {
337
+ rootPrivateKey: `0x${string}`;
338
+ slot: number;
339
+ relayUrl?: string;
340
+ rpcUrl?: string;
341
+ }
342
+
343
+ /**
344
+ * Relay-backed forwarder sweep request
345
+ */
346
+ export interface SubaccountSweepRequest {
347
+ forwarderAddress: `0x${string}`;
348
+ asset: SubaccountAsset;
349
+ relayUrl?: string;
350
+ }
351
+
352
+ /**
353
+ * Relay response for subaccount deploy/sweep operations
354
+ */
355
+ export interface SubaccountRelayResult {
356
+ success: boolean;
357
+ transactionHash: string;
358
+ blockNumber: string;
359
+ gasUsed: string;
360
+ status: string;
361
+ network: string;
362
+ }
363
+
364
+ /**
365
+ * Human-readable and wei-denominated balance
366
+ */
367
+ export interface SubaccountAssetBalance {
368
+ balance: string;
369
+ balanceWei: string;
370
+ }
371
+
372
+ /**
373
+ * Forwarder wallet balances for both supported assets
374
+ */
375
+ export interface SubaccountBalances {
376
+ eth: SubaccountAssetBalance;
377
+ usdc: SubaccountAssetBalance;
378
+ }
379
+
380
+ /**
381
+ * Queue status for a specific asset
382
+ */
383
+ export interface SubaccountQueueStatus {
384
+ asset: SubaccountAsset;
385
+ queueBalance: string;
386
+ queueBalanceWei: string;
387
+ pendingCount: number;
388
+ pendingDeposits: PendingDeposit[];
389
+ }
390
+
391
+ /**
392
+ * Combined subaccount status result
393
+ */
394
+ export interface SubaccountStatusResult {
395
+ slot: SubaccountSlot;
396
+ deployed: boolean;
397
+ balances: SubaccountBalances;
398
+ queues: {
399
+ eth: SubaccountQueueStatus;
400
+ usdc: SubaccountQueueStatus;
401
+ };
402
+ }
403
+
404
+ /**
405
+ * Typed data for forwarder withdraw signing
406
+ */
407
+ export interface SubaccountWithdrawTypedData {
408
+ domain: {
409
+ name: 'VeilForwarder';
410
+ version: string;
411
+ chainId: number;
412
+ verifyingContract: `0x${string}`;
413
+ };
414
+ types: {
415
+ Withdraw: Array<{
416
+ name: 'token' | 'to' | 'amount' | 'nonce' | 'deadline';
417
+ type: 'address' | 'uint256';
418
+ }>;
419
+ };
420
+ primaryType: 'Withdraw';
421
+ message: {
422
+ token: `0x${string}`;
423
+ to: `0x${string}`;
424
+ amount: bigint;
425
+ nonce: bigint;
426
+ deadline: bigint;
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Built recovery transaction and signing metadata
432
+ */
433
+ export interface SubaccountRecoveryResult {
434
+ transaction: TransactionData;
435
+ forwarderAddress: `0x${string}`;
436
+ asset: SubaccountAsset;
437
+ amount: string;
438
+ amountWei: string;
439
+ nonce: string;
440
+ deadline: string;
441
+ recipient: `0x${string}`;
442
+ tokenAddress: `0x${string}`;
443
+ signature: `0x${string}`;
444
+ }