@veil-cash/sdk 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/SDK.md +20 -2
- package/dist/cli/index.cjs +257 -13
- package/dist/index.cjs +185 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +84 -2
- package/dist/index.d.ts +84 -2
- package/dist/index.js +184 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/veil/SKILL.md +26 -8
- package/skills/veil/reference.md +23 -3
- package/src/cli/commands/subaccount.ts +82 -2
- package/src/cli/index.ts +1 -1
- package/src/index.ts +6 -0
- package/src/subaccount.ts +244 -5
- package/src/types.ts +56 -0
package/package.json
CHANGED
package/skills/veil/SKILL.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: veil
|
|
3
|
-
version: 0.6.
|
|
3
|
+
version: 0.6.2
|
|
4
4
|
description: >
|
|
5
5
|
Veil CLI for private ETH and USDC transactions on Base. Use when the user wants
|
|
6
6
|
to deposit, withdraw, or transfer assets privately, check private balances,
|
|
7
7
|
manage Veil keypairs, register on-chain, manage deterministic subaccounts
|
|
8
|
-
(forwarder deploy, sweep, recover), or build unsigned
|
|
9
|
-
for an external signer (e.g. Bankr). All operations
|
|
8
|
+
(forwarder deploy, sweep, merge to main wallet, recover), or build unsigned
|
|
9
|
+
transaction payloads for an external signer (e.g. Bankr). All operations
|
|
10
|
+
target Base (chain ID 8453).
|
|
10
11
|
author: veildotcash
|
|
11
12
|
metadata:
|
|
12
13
|
homepage: https://veil.cash
|
|
@@ -35,6 +36,7 @@ triggers:
|
|
|
35
36
|
- pattern: withdraw privately
|
|
36
37
|
- pattern: private transfer
|
|
37
38
|
- pattern: subaccount
|
|
39
|
+
- pattern: subaccount merge
|
|
38
40
|
- pattern: forwarder
|
|
39
41
|
- pattern: stealth deposit
|
|
40
42
|
---
|
|
@@ -200,6 +202,7 @@ What do you want to do?
|
|
|
200
202
|
| Subaccount address | `veil subaccount address --slot 0` |
|
|
201
203
|
| Deploy forwarder | `veil subaccount deploy --slot 0` |
|
|
202
204
|
| Sweep forwarder | `veil subaccount sweep --slot 0 --asset eth` |
|
|
205
|
+
| Merge subaccount to main | `veil subaccount merge --slot 0 --pool eth` |
|
|
203
206
|
| Recover from forwarder | `veil subaccount recover --slot 0 --asset usdc --to 0xAddr --amount 25` |
|
|
204
207
|
|
|
205
208
|
---
|
|
@@ -428,11 +431,13 @@ Subaccounts are deterministic child slots derived from your main `VEIL_KEY`:
|
|
|
428
431
|
`root key → slot → child key → child deposit key → forwarder`
|
|
429
432
|
|
|
430
433
|
Base mainnet only. Slots are `0`–`2` (max 3 subaccounts). Deploy and sweep are
|
|
431
|
-
relay-backed (no `WALLET_KEY` needed).
|
|
432
|
-
|
|
434
|
+
relay-backed (no `WALLET_KEY` needed). Merge transfers the subaccount's private
|
|
435
|
+
pool balance back to the main wallet via a ZK proof (relay-backed, no `WALLET_KEY`
|
|
436
|
+
needed). Recovery submits a direct on-chain transaction and **requires `WALLET_KEY`**
|
|
437
|
+
as a gas payer.
|
|
433
438
|
|
|
434
|
-
Status reports the forwarder wallet balances
|
|
435
|
-
|
|
439
|
+
Status reports the child slot's forwarder wallet balances, private pool
|
|
440
|
+
balances, and queue state.
|
|
436
441
|
|
|
437
442
|
### Derive and inspect
|
|
438
443
|
|
|
@@ -440,7 +445,7 @@ pool attribution after queued funds are accepted.
|
|
|
440
445
|
veil subaccount derive --slot 0 # Full slot metadata
|
|
441
446
|
veil subaccount derive --slot 0 --json
|
|
442
447
|
veil subaccount address --slot 0 # Just the forwarder address
|
|
443
|
-
veil subaccount status --slot 0 # Deployment, balances, queue state
|
|
448
|
+
veil subaccount status --slot 0 # Deployment, forwarder balances, private balances, queue state
|
|
444
449
|
veil subaccount status --slot 0 --json
|
|
445
450
|
```
|
|
446
451
|
|
|
@@ -454,6 +459,18 @@ veil subaccount sweep --slot 0 --asset usdc # Sweep USDC into the pool
|
|
|
454
459
|
veil subaccount sweep --slot 0 --asset eth --json
|
|
455
460
|
```
|
|
456
461
|
|
|
462
|
+
### Merge subaccount to main wallet (relay-backed)
|
|
463
|
+
|
|
464
|
+
Merge transfers the subaccount's entire private pool balance back to the main
|
|
465
|
+
wallet. It builds a ZK proof transferring child UTXOs to the parent keypair and
|
|
466
|
+
submits via the relay. Only needs `VEIL_KEY`.
|
|
467
|
+
|
|
468
|
+
```bash
|
|
469
|
+
veil subaccount merge --slot 0 --pool eth
|
|
470
|
+
veil subaccount merge --slot 0 --pool usdc
|
|
471
|
+
veil subaccount merge --slot 0 --pool eth --json
|
|
472
|
+
```
|
|
473
|
+
|
|
457
474
|
### Recover (direct on-chain — requires WALLET_KEY)
|
|
458
475
|
|
|
459
476
|
Recovery is for assets still sitting on the forwarder after refund or rejection.
|
|
@@ -470,6 +487,7 @@ Important:
|
|
|
470
487
|
- `--asset` is `eth` or `usdc` (case-insensitive in the CLI)
|
|
471
488
|
- `--slot` is `0`–`2`
|
|
472
489
|
- Deploy and sweep only need `VEIL_KEY`
|
|
490
|
+
- Merge only needs `VEIL_KEY`
|
|
473
491
|
- Recover needs both `VEIL_KEY` and `WALLET_KEY`
|
|
474
492
|
|
|
475
493
|
---
|
package/skills/veil/reference.md
CHANGED
|
@@ -148,9 +148,11 @@ const priv = await getPrivateBalance({
|
|
|
148
148
|
```typescript
|
|
149
149
|
import {
|
|
150
150
|
deriveSubaccountSlot,
|
|
151
|
+
getSubaccountPrivateBalance,
|
|
151
152
|
getSubaccountStatus,
|
|
152
153
|
deploySubaccountForwarder,
|
|
153
154
|
sweepSubaccountForwarder,
|
|
155
|
+
mergeSubaccount,
|
|
154
156
|
buildSubaccountRecoveryTx,
|
|
155
157
|
isSubaccountForwarderDeployed,
|
|
156
158
|
MAX_SUBACCOUNT_SLOTS,
|
|
@@ -168,12 +170,20 @@ const deployed = await isSubaccountForwarderDeployed({
|
|
|
168
170
|
forwarderAddress: slot.forwarderAddress,
|
|
169
171
|
});
|
|
170
172
|
|
|
171
|
-
// Full status (deployment, balances, queue state)
|
|
173
|
+
// Full status (deployment, forwarder balances, private balances, queue state)
|
|
172
174
|
const status = await getSubaccountStatus({
|
|
173
175
|
rootPrivateKey: '0xVEIL_KEY',
|
|
174
176
|
slot: 0,
|
|
175
177
|
});
|
|
176
|
-
// status.deployed, status.balances
|
|
178
|
+
// status.deployed, status.balances, status.privateBalances, status.queues
|
|
179
|
+
|
|
180
|
+
// Private pool balance for a single slot + pool
|
|
181
|
+
const privateBalance = await getSubaccountPrivateBalance({
|
|
182
|
+
rootPrivateKey: '0xVEIL_KEY',
|
|
183
|
+
slot: 0,
|
|
184
|
+
pool: 'eth',
|
|
185
|
+
});
|
|
186
|
+
// privateBalance.privateBalance, privateBalance.unspentCount, privateBalance.utxos
|
|
177
187
|
|
|
178
188
|
// Deploy forwarder (relay-backed, no WALLET_KEY needed)
|
|
179
189
|
const deployResult = await deploySubaccountForwarder({
|
|
@@ -188,6 +198,14 @@ const sweepResult = await sweepSubaccountForwarder({
|
|
|
188
198
|
asset: 'eth', // 'eth' | 'usdc'
|
|
189
199
|
});
|
|
190
200
|
|
|
201
|
+
// Merge subaccount's private balance back to main wallet (relay-backed)
|
|
202
|
+
const mergeResult = await mergeSubaccount({
|
|
203
|
+
rootPrivateKey: '0xVEIL_KEY',
|
|
204
|
+
slot: 0,
|
|
205
|
+
pool: 'eth', // 'eth' | 'usdc' (default: 'eth')
|
|
206
|
+
});
|
|
207
|
+
// mergeResult.success, mergeResult.transactionHash, mergeResult.amount, mergeResult.slot, mergeResult.pool
|
|
208
|
+
|
|
191
209
|
// Build recovery transaction (for assets stuck on forwarder)
|
|
192
210
|
const recovery = await buildSubaccountRecoveryTx({
|
|
193
211
|
rootPrivateKey: '0xVEIL_KEY',
|
|
@@ -256,12 +274,14 @@ veil balance private --json # Private balance as JSON
|
|
|
256
274
|
veil subaccount derive --slot 0 # Derive slot metadata
|
|
257
275
|
veil subaccount derive --slot 0 --json # Derive as JSON
|
|
258
276
|
veil subaccount address --slot 0 # Print forwarder address
|
|
259
|
-
veil subaccount status --slot 0 # Deployment, balances, queue state
|
|
277
|
+
veil subaccount status --slot 0 # Deployment, forwarder balances, private balances, queue state
|
|
260
278
|
veil subaccount status --slot 0 --json # Status as JSON
|
|
261
279
|
veil subaccount deploy --slot 0 # Deploy forwarder (relay-backed)
|
|
262
280
|
veil subaccount deploy --slot 0 --json # Deploy as JSON
|
|
263
281
|
veil subaccount sweep --slot 0 --asset eth # Sweep ETH into pool (relay-backed)
|
|
264
282
|
veil subaccount sweep --slot 0 --asset usdc --json # Sweep USDC as JSON
|
|
283
|
+
veil subaccount merge --slot 0 --pool eth # Merge subaccount balance to main wallet
|
|
284
|
+
veil subaccount merge --slot 0 --pool usdc --json # Merge USDC as JSON
|
|
265
285
|
veil subaccount recover --slot 0 --asset usdc --to 0x... --amount 25 # Recover assets (needs WALLET_KEY)
|
|
266
286
|
veil subaccount recover --slot 0 --asset eth --to 0x... --amount 0.05 --json
|
|
267
287
|
```
|
|
@@ -7,13 +7,14 @@ import {
|
|
|
7
7
|
getSubaccountStatus,
|
|
8
8
|
isSubaccountForwarderDeployed,
|
|
9
9
|
MAX_SUBACCOUNT_SLOTS,
|
|
10
|
+
mergeSubaccount,
|
|
10
11
|
sweepSubaccountForwarder,
|
|
11
12
|
} from '../../subaccount.js';
|
|
12
13
|
import { getConfig } from '../config.js';
|
|
13
14
|
import { CLIError, ErrorCode, handleCLIError } from '../errors.js';
|
|
14
15
|
import { printFields, printHeader, printJson, printLine, printList, printSection, txUrl } from '../output.js';
|
|
15
16
|
import { sendTransaction } from '../wallet.js';
|
|
16
|
-
import type { SubaccountAsset } from '../../types.js';
|
|
17
|
+
import type { SubaccountAsset, RelayPool } from '../../types.js';
|
|
17
18
|
|
|
18
19
|
function parseSlotValue(raw: string): number {
|
|
19
20
|
const normalized = raw.trim();
|
|
@@ -51,6 +52,14 @@ function parseAsset(raw: string): SubaccountAsset {
|
|
|
51
52
|
return asset;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
function parsePool(raw: string): RelayPool {
|
|
56
|
+
const pool = raw.toLowerCase();
|
|
57
|
+
if (pool !== 'eth' && pool !== 'usdc') {
|
|
58
|
+
throw new CLIError(ErrorCode.INVALID_AMOUNT, `Unsupported pool: ${raw}. Supported: eth, usdc`);
|
|
59
|
+
}
|
|
60
|
+
return pool;
|
|
61
|
+
}
|
|
62
|
+
|
|
54
63
|
function printQueueHuman(
|
|
55
64
|
title: string,
|
|
56
65
|
queue: {
|
|
@@ -81,6 +90,7 @@ Examples:
|
|
|
81
90
|
veil subaccount status --slot 0
|
|
82
91
|
veil subaccount deploy --slot 0
|
|
83
92
|
veil subaccount sweep --slot 0 --asset eth
|
|
93
|
+
veil subaccount merge --slot 0 --pool eth
|
|
84
94
|
veil subaccount recover --slot 0 --asset usdc --to 0xRecipientAddress --amount 25
|
|
85
95
|
veil subaccount address --slot 0
|
|
86
96
|
`);
|
|
@@ -130,7 +140,7 @@ Examples:
|
|
|
130
140
|
|
|
131
141
|
subaccount
|
|
132
142
|
.command('status')
|
|
133
|
-
.description('Show subaccount deployment, balances, and queue state')
|
|
143
|
+
.description('Show subaccount deployment, forwarder balances, private balances, and queue state')
|
|
134
144
|
.requiredOption('--slot <n>', 'Subaccount slot', parseSlotValue)
|
|
135
145
|
.option('--json', 'Output as JSON')
|
|
136
146
|
.action(async (options) => {
|
|
@@ -162,6 +172,18 @@ Examples:
|
|
|
162
172
|
{ label: 'USDC', value: `${status.balances.usdc.balance} USDC` },
|
|
163
173
|
]);
|
|
164
174
|
|
|
175
|
+
printSection('Private Pool Balances');
|
|
176
|
+
printFields([
|
|
177
|
+
{
|
|
178
|
+
label: 'ETH',
|
|
179
|
+
value: `${status.privateBalances.eth.privateBalance} ETH (${status.privateBalances.eth.unspentCount} unspent / ${status.privateBalances.eth.spentCount} spent / ${status.privateBalances.eth.utxoCount} total UTXOs)`,
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
label: 'USDC',
|
|
183
|
+
value: `${status.privateBalances.usdc.privateBalance} USDC (${status.privateBalances.usdc.unspentCount} unspent / ${status.privateBalances.usdc.spentCount} spent / ${status.privateBalances.usdc.utxoCount} total UTXOs)`,
|
|
184
|
+
},
|
|
185
|
+
]);
|
|
186
|
+
|
|
165
187
|
printQueueHuman('ETH Queue', status.queues.eth);
|
|
166
188
|
printQueueHuman('USDC Queue', status.queues.usdc);
|
|
167
189
|
printLine();
|
|
@@ -255,6 +277,64 @@ Examples:
|
|
|
255
277
|
}
|
|
256
278
|
});
|
|
257
279
|
|
|
280
|
+
subaccount
|
|
281
|
+
.command('merge')
|
|
282
|
+
.description('Merge a subaccount\'s private pool balance back to the main wallet')
|
|
283
|
+
.requiredOption('--slot <n>', 'Subaccount slot', parseSlotValue)
|
|
284
|
+
.option('--pool <pool>', 'Pool to merge (eth or usdc)', parsePool, 'eth' as RelayPool)
|
|
285
|
+
.option('--json', 'Output as JSON')
|
|
286
|
+
.action(async (options) => {
|
|
287
|
+
try {
|
|
288
|
+
const rootPrivateKey = getRequiredVeilKey();
|
|
289
|
+
const result = await mergeSubaccount({
|
|
290
|
+
rootPrivateKey,
|
|
291
|
+
slot: options.slot,
|
|
292
|
+
pool: options.pool,
|
|
293
|
+
rpcUrl: process.env.RPC_URL,
|
|
294
|
+
relayUrl: process.env.RELAY_URL,
|
|
295
|
+
onProgress: options.json
|
|
296
|
+
? undefined
|
|
297
|
+
: (stage, detail) => {
|
|
298
|
+
const msg = detail ? `${stage} ${detail}` : stage;
|
|
299
|
+
process.stderr.write(`\r\x1b[K${msg}`);
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
if (!options.json) {
|
|
304
|
+
process.stderr.write('\r\x1b[K');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const output = {
|
|
308
|
+
success: result.success,
|
|
309
|
+
slot: result.slot,
|
|
310
|
+
pool: result.pool,
|
|
311
|
+
amount: result.amount,
|
|
312
|
+
transactionHash: result.transactionHash,
|
|
313
|
+
blockNumber: result.blockNumber,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
if (options.json) {
|
|
317
|
+
printJson(output);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
printHeader('Subaccount Merge Submitted');
|
|
322
|
+
printFields([
|
|
323
|
+
{ label: 'Slot', value: result.slot },
|
|
324
|
+
{ label: 'Pool', value: result.pool.toUpperCase() },
|
|
325
|
+
{ label: 'Amount', value: result.amount },
|
|
326
|
+
{ label: 'Transaction', value: txUrl(result.transactionHash) },
|
|
327
|
+
{ label: 'Block', value: result.blockNumber },
|
|
328
|
+
]);
|
|
329
|
+
printLine();
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (!options.json) {
|
|
332
|
+
process.stderr.write('\r\x1b[K');
|
|
333
|
+
}
|
|
334
|
+
handleCLIError(error);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
258
338
|
subaccount
|
|
259
339
|
.command('recover')
|
|
260
340
|
.description('Recover assets sitting on the subaccount forwarder with a direct withdraw transaction')
|
package/src/cli/index.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -139,12 +139,14 @@ export {
|
|
|
139
139
|
isSubaccountForwarderDeployed,
|
|
140
140
|
deploySubaccountForwarder,
|
|
141
141
|
sweepSubaccountForwarder,
|
|
142
|
+
getSubaccountPrivateBalance,
|
|
142
143
|
getSubaccountStatus,
|
|
143
144
|
buildSubaccountWithdrawTypedData,
|
|
144
145
|
signSubaccountWithdraw,
|
|
145
146
|
isSubaccountWithdrawNonceUsed,
|
|
146
147
|
findNextSubaccountWithdrawNonce,
|
|
147
148
|
buildSubaccountRecoveryTx,
|
|
149
|
+
mergeSubaccount,
|
|
148
150
|
} from './subaccount.js';
|
|
149
151
|
|
|
150
152
|
// Utilities
|
|
@@ -197,8 +199,12 @@ export type {
|
|
|
197
199
|
SubaccountRelayResult,
|
|
198
200
|
SubaccountAssetBalance,
|
|
199
201
|
SubaccountBalances,
|
|
202
|
+
SubaccountPrivateBalanceStatus,
|
|
203
|
+
SubaccountPrivateBalances,
|
|
200
204
|
SubaccountQueueStatus,
|
|
201
205
|
SubaccountStatusResult,
|
|
202
206
|
SubaccountWithdrawTypedData,
|
|
203
207
|
SubaccountRecoveryResult,
|
|
208
|
+
SubaccountMergeOptions,
|
|
209
|
+
SubaccountMergeResult,
|
|
204
210
|
} from './types.js';
|
package/src/subaccount.ts
CHANGED
|
@@ -12,14 +12,21 @@ import {
|
|
|
12
12
|
} from 'viem';
|
|
13
13
|
import { privateKeyToAccount, privateKeyToAddress } from 'viem/accounts';
|
|
14
14
|
import { base } from 'viem/chains';
|
|
15
|
-
import { FORWARDER_ABI, FORWARDER_FACTORY_ABI, ERC20_ABI } from './abi.js';
|
|
16
|
-
import { FORWARDER_CONTRACT_VERSION, getAddresses, getForwarderFactoryAddress } from './addresses.js';
|
|
17
|
-
import { getQueueBalance } from './balance.js';
|
|
15
|
+
import { FORWARDER_ABI, FORWARDER_FACTORY_ABI, ERC20_ABI, POOL_ABI } from './abi.js';
|
|
16
|
+
import { FORWARDER_CONTRACT_VERSION, getAddresses, getForwarderFactoryAddress, getPoolAddress, POOL_CONFIG } from './addresses.js';
|
|
17
|
+
import { getPrivateBalance, getQueueBalance } from './balance.js';
|
|
18
18
|
import { Keypair } from './keypair.js';
|
|
19
|
-
import { postRelayJson } from './relay.js';
|
|
19
|
+
import { postRelayJson, submitRelay } from './relay.js';
|
|
20
|
+
import { prepareTransaction } from './transaction.js';
|
|
21
|
+
import { Utxo } from './utxo.js';
|
|
22
|
+
import { selectUtxosForWithdraw } from './withdraw.js';
|
|
20
23
|
import type {
|
|
21
24
|
SubaccountAsset,
|
|
22
25
|
SubaccountDeployRequest,
|
|
26
|
+
SubaccountMergeOptions,
|
|
27
|
+
SubaccountMergeResult,
|
|
28
|
+
PrivateBalanceResult,
|
|
29
|
+
SubaccountPrivateBalanceStatus,
|
|
23
30
|
SubaccountQueueStatus,
|
|
24
31
|
SubaccountRecoveryResult,
|
|
25
32
|
SubaccountRelayResult,
|
|
@@ -238,6 +245,37 @@ function toQueueStatus(
|
|
|
238
245
|
};
|
|
239
246
|
}
|
|
240
247
|
|
|
248
|
+
function toPrivateBalanceStatus(result: PrivateBalanceResult): SubaccountPrivateBalanceStatus {
|
|
249
|
+
return {
|
|
250
|
+
privateBalance: result.privateBalance,
|
|
251
|
+
privateBalanceWei: result.privateBalanceWei,
|
|
252
|
+
utxoCount: result.utxoCount,
|
|
253
|
+
spentCount: result.spentCount,
|
|
254
|
+
unspentCount: result.unspentCount,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function getSubaccountPrivateBalance(options: {
|
|
259
|
+
rootPrivateKey: `0x${string}`;
|
|
260
|
+
slot: number;
|
|
261
|
+
pool?: 'eth' | 'usdc';
|
|
262
|
+
rpcUrl?: string;
|
|
263
|
+
onProgress?: (stage: string, detail?: string) => void;
|
|
264
|
+
}): Promise<PrivateBalanceResult> {
|
|
265
|
+
const normalizedSlot = normalizeSlot(options.slot);
|
|
266
|
+
assertPrivateKey(options.rootPrivateKey, 'rootPrivateKey');
|
|
267
|
+
|
|
268
|
+
const childPrivateKey = deriveSubaccountChildPrivateKey(options.rootPrivateKey, normalizedSlot);
|
|
269
|
+
const childKeypair = new Keypair(childPrivateKey);
|
|
270
|
+
|
|
271
|
+
return getPrivateBalance({
|
|
272
|
+
keypair: childKeypair,
|
|
273
|
+
pool: options.pool,
|
|
274
|
+
rpcUrl: options.rpcUrl,
|
|
275
|
+
onProgress: options.onProgress,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
241
279
|
export async function getSubaccountStatus(options: {
|
|
242
280
|
rootPrivateKey: `0x${string}`;
|
|
243
281
|
slot: number;
|
|
@@ -247,7 +285,7 @@ export async function getSubaccountStatus(options: {
|
|
|
247
285
|
const publicClient = createBaseClient(options.rpcUrl);
|
|
248
286
|
const addresses = getAddresses();
|
|
249
287
|
|
|
250
|
-
const [deployed, ethWei, usdcWei, ethQueue, usdcQueue] = await Promise.all([
|
|
288
|
+
const [deployed, ethWei, usdcWei, ethQueue, usdcQueue, ethPrivate, usdcPrivate] = await Promise.all([
|
|
251
289
|
isSubaccountForwarderDeployed({
|
|
252
290
|
forwarderAddress: slot.forwarderAddress,
|
|
253
291
|
rpcUrl: options.rpcUrl,
|
|
@@ -269,6 +307,18 @@ export async function getSubaccountStatus(options: {
|
|
|
269
307
|
pool: 'usdc',
|
|
270
308
|
rpcUrl: options.rpcUrl,
|
|
271
309
|
}),
|
|
310
|
+
getSubaccountPrivateBalance({
|
|
311
|
+
rootPrivateKey: options.rootPrivateKey,
|
|
312
|
+
slot: options.slot,
|
|
313
|
+
pool: 'eth',
|
|
314
|
+
rpcUrl: options.rpcUrl,
|
|
315
|
+
}),
|
|
316
|
+
getSubaccountPrivateBalance({
|
|
317
|
+
rootPrivateKey: options.rootPrivateKey,
|
|
318
|
+
slot: options.slot,
|
|
319
|
+
pool: 'usdc',
|
|
320
|
+
rpcUrl: options.rpcUrl,
|
|
321
|
+
}),
|
|
272
322
|
]);
|
|
273
323
|
|
|
274
324
|
return {
|
|
@@ -284,6 +334,10 @@ export async function getSubaccountStatus(options: {
|
|
|
284
334
|
balanceWei: usdcWei.toString(),
|
|
285
335
|
},
|
|
286
336
|
},
|
|
337
|
+
privateBalances: {
|
|
338
|
+
eth: toPrivateBalanceStatus(ethPrivate),
|
|
339
|
+
usdc: toPrivateBalanceStatus(usdcPrivate),
|
|
340
|
+
},
|
|
287
341
|
queues: {
|
|
288
342
|
eth: toQueueStatus('eth', ethQueue),
|
|
289
343
|
usdc: toQueueStatus('usdc', usdcQueue),
|
|
@@ -479,3 +533,188 @@ export async function buildSubaccountRecoveryTx(options: {
|
|
|
479
533
|
signature,
|
|
480
534
|
};
|
|
481
535
|
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Merge a subaccount's entire private balance back to the main wallet.
|
|
539
|
+
*
|
|
540
|
+
* Builds a ZK transfer proof that moves every unspent UTXO belonging to the
|
|
541
|
+
* child keypair into a new UTXO encrypted to the parent (root) keypair,
|
|
542
|
+
* then submits it via the relay.
|
|
543
|
+
*
|
|
544
|
+
* @param options - Merge options
|
|
545
|
+
* @returns Merge result with transaction hash and amount
|
|
546
|
+
*
|
|
547
|
+
* @example
|
|
548
|
+
* ```typescript
|
|
549
|
+
* const result = await mergeSubaccount({
|
|
550
|
+
* rootPrivateKey: process.env.VEIL_KEY as `0x${string}`,
|
|
551
|
+
* slot: 0,
|
|
552
|
+
* pool: 'eth',
|
|
553
|
+
* });
|
|
554
|
+
* console.log(`Merged ${result.amount} — tx: ${result.transactionHash}`);
|
|
555
|
+
* ```
|
|
556
|
+
*/
|
|
557
|
+
export async function mergeSubaccount(
|
|
558
|
+
options: SubaccountMergeOptions,
|
|
559
|
+
): Promise<SubaccountMergeResult> {
|
|
560
|
+
const {
|
|
561
|
+
rootPrivateKey,
|
|
562
|
+
slot,
|
|
563
|
+
pool = 'eth',
|
|
564
|
+
rpcUrl,
|
|
565
|
+
relayUrl,
|
|
566
|
+
onProgress,
|
|
567
|
+
} = options;
|
|
568
|
+
|
|
569
|
+
const normalizedSlot = normalizeSlot(slot);
|
|
570
|
+
assertPrivateKey(rootPrivateKey, 'rootPrivateKey');
|
|
571
|
+
|
|
572
|
+
const poolConfig = POOL_CONFIG[pool];
|
|
573
|
+
const poolAddress = getPoolAddress(pool);
|
|
574
|
+
|
|
575
|
+
// Derive child and parent keypairs
|
|
576
|
+
const childPrivateKey = deriveSubaccountChildPrivateKey(rootPrivateKey, normalizedSlot);
|
|
577
|
+
const childKeypair = new Keypair(childPrivateKey);
|
|
578
|
+
const parentKeypair = new Keypair(rootPrivateKey);
|
|
579
|
+
|
|
580
|
+
// Fetch child's private balance
|
|
581
|
+
onProgress?.('Fetching subaccount balance...');
|
|
582
|
+
const balanceResult = await getPrivateBalance({
|
|
583
|
+
keypair: childKeypair,
|
|
584
|
+
pool,
|
|
585
|
+
rpcUrl,
|
|
586
|
+
onProgress,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
const unspentUtxoInfos = balanceResult.utxos.filter(u => !u.isSpent);
|
|
590
|
+
if (unspentUtxoInfos.length === 0) {
|
|
591
|
+
throw new Error('Subaccount has no unspent UTXOs to merge');
|
|
592
|
+
}
|
|
593
|
+
if (unspentUtxoInfos.length > 16) {
|
|
594
|
+
throw new Error(
|
|
595
|
+
`Subaccount has ${unspentUtxoInfos.length} unspent UTXOs which exceeds the 16-input circuit limit. ` +
|
|
596
|
+
'Consolidate UTXOs on the subaccount first before merging.',
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Re-decrypt UTXOs to get full Utxo objects
|
|
601
|
+
onProgress?.('Preparing UTXOs...');
|
|
602
|
+
const publicClient = createPublicClient({
|
|
603
|
+
chain: base,
|
|
604
|
+
transport: http(rpcUrl),
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
const utxos: Utxo[] = [];
|
|
608
|
+
for (const utxoInfo of unspentUtxoInfos) {
|
|
609
|
+
const encryptedOutputs = await publicClient.readContract({
|
|
610
|
+
address: poolAddress,
|
|
611
|
+
abi: POOL_ABI,
|
|
612
|
+
functionName: 'getEncryptedOutputs',
|
|
613
|
+
args: [BigInt(utxoInfo.index), BigInt(utxoInfo.index + 1)],
|
|
614
|
+
}) as string[];
|
|
615
|
+
|
|
616
|
+
if (encryptedOutputs.length > 0) {
|
|
617
|
+
try {
|
|
618
|
+
const utxo = Utxo.decrypt(encryptedOutputs[0], childKeypair);
|
|
619
|
+
utxo.index = utxoInfo.index;
|
|
620
|
+
utxos.push(utxo);
|
|
621
|
+
} catch {
|
|
622
|
+
// Skip if decryption fails
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (utxos.length === 0) {
|
|
628
|
+
throw new Error('Failed to decrypt subaccount UTXOs');
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Select all UTXOs — transfer the full balance
|
|
632
|
+
onProgress?.('Selecting UTXOs...');
|
|
633
|
+
const amount = balanceResult.privateBalance;
|
|
634
|
+
const { selectedUtxos, changeAmount } = selectUtxosForWithdraw(
|
|
635
|
+
utxos,
|
|
636
|
+
amount,
|
|
637
|
+
poolConfig.decimals,
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
// Create output UTXO encrypted to the parent keypair
|
|
641
|
+
const outputs: Utxo[] = [];
|
|
642
|
+
const mergeWei = parseUnits(amount, poolConfig.decimals);
|
|
643
|
+
|
|
644
|
+
outputs.push(new Utxo({ amount: mergeWei, keypair: parentKeypair }));
|
|
645
|
+
|
|
646
|
+
if (changeAmount > 0n) {
|
|
647
|
+
outputs.push(new Utxo({ amount: changeAmount, keypair: parentKeypair }));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Fetch all commitments from pool
|
|
651
|
+
onProgress?.('Fetching commitments...');
|
|
652
|
+
const nextIndex = await publicClient.readContract({
|
|
653
|
+
address: poolAddress,
|
|
654
|
+
abi: POOL_ABI,
|
|
655
|
+
functionName: 'nextIndex',
|
|
656
|
+
}) as number;
|
|
657
|
+
|
|
658
|
+
const BATCH_SIZE = 5000;
|
|
659
|
+
const commitments: string[] = [];
|
|
660
|
+
const totalBatches = Math.ceil(nextIndex / BATCH_SIZE);
|
|
661
|
+
|
|
662
|
+
for (let start = 0; start < nextIndex; start += BATCH_SIZE) {
|
|
663
|
+
const end = Math.min(start + BATCH_SIZE, nextIndex);
|
|
664
|
+
const batchNum = Math.floor(start / BATCH_SIZE) + 1;
|
|
665
|
+
onProgress?.('Fetching commitments', `batch ${batchNum}/${totalBatches}`);
|
|
666
|
+
|
|
667
|
+
const batch = await publicClient.readContract({
|
|
668
|
+
address: poolAddress,
|
|
669
|
+
abi: POOL_ABI,
|
|
670
|
+
functionName: 'getCommitments',
|
|
671
|
+
args: [BigInt(start), BigInt(end)],
|
|
672
|
+
}) as `0x${string}`[];
|
|
673
|
+
|
|
674
|
+
commitments.push(...batch.map(c => c.toString()));
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Build ZK proof (recipient = 0x0 for in-pool transfer)
|
|
678
|
+
onProgress?.('Building ZK proof...');
|
|
679
|
+
const result = await prepareTransaction({
|
|
680
|
+
commitments,
|
|
681
|
+
inputs: selectedUtxos,
|
|
682
|
+
outputs,
|
|
683
|
+
fee: 0,
|
|
684
|
+
recipient: '0x0000000000000000000000000000000000000000',
|
|
685
|
+
relayer: '0x0000000000000000000000000000000000000000',
|
|
686
|
+
onProgress,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// Submit to relay
|
|
690
|
+
onProgress?.('Submitting to relay...');
|
|
691
|
+
const relayResult = await submitRelay({
|
|
692
|
+
type: 'transfer',
|
|
693
|
+
pool,
|
|
694
|
+
relayUrl,
|
|
695
|
+
proofArgs: {
|
|
696
|
+
proof: result.args.proof,
|
|
697
|
+
root: result.args.root,
|
|
698
|
+
inputNullifiers: result.args.inputNullifiers,
|
|
699
|
+
outputCommitments: result.args.outputCommitments as [string, string],
|
|
700
|
+
publicAmount: result.args.publicAmount,
|
|
701
|
+
extDataHash: result.args.extDataHash,
|
|
702
|
+
},
|
|
703
|
+
extData: result.extData,
|
|
704
|
+
metadata: {
|
|
705
|
+
amount,
|
|
706
|
+
recipient: 'self',
|
|
707
|
+
inputUtxoCount: selectedUtxos.length,
|
|
708
|
+
outputUtxoCount: outputs.length,
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
return {
|
|
713
|
+
success: relayResult.success,
|
|
714
|
+
transactionHash: relayResult.transactionHash,
|
|
715
|
+
blockNumber: relayResult.blockNumber,
|
|
716
|
+
amount,
|
|
717
|
+
slot: normalizedSlot,
|
|
718
|
+
pool,
|
|
719
|
+
};
|
|
720
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -377,6 +377,25 @@ export interface SubaccountBalances {
|
|
|
377
377
|
usdc: SubaccountAssetBalance;
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Private pool balance summary for a specific asset
|
|
382
|
+
*/
|
|
383
|
+
export interface SubaccountPrivateBalanceStatus {
|
|
384
|
+
privateBalance: string;
|
|
385
|
+
privateBalanceWei: string;
|
|
386
|
+
utxoCount: number;
|
|
387
|
+
spentCount: number;
|
|
388
|
+
unspentCount: number;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Private pool balances for both supported assets
|
|
393
|
+
*/
|
|
394
|
+
export interface SubaccountPrivateBalances {
|
|
395
|
+
eth: SubaccountPrivateBalanceStatus;
|
|
396
|
+
usdc: SubaccountPrivateBalanceStatus;
|
|
397
|
+
}
|
|
398
|
+
|
|
380
399
|
/**
|
|
381
400
|
* Queue status for a specific asset
|
|
382
401
|
*/
|
|
@@ -395,6 +414,7 @@ export interface SubaccountStatusResult {
|
|
|
395
414
|
slot: SubaccountSlot;
|
|
396
415
|
deployed: boolean;
|
|
397
416
|
balances: SubaccountBalances;
|
|
417
|
+
privateBalances: SubaccountPrivateBalances;
|
|
398
418
|
queues: {
|
|
399
419
|
eth: SubaccountQueueStatus;
|
|
400
420
|
usdc: SubaccountQueueStatus;
|
|
@@ -427,6 +447,42 @@ export interface SubaccountWithdrawTypedData {
|
|
|
427
447
|
};
|
|
428
448
|
}
|
|
429
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Options for merging a subaccount's private balance back to the main wallet
|
|
452
|
+
*/
|
|
453
|
+
export interface SubaccountMergeOptions {
|
|
454
|
+
/** Root private key (VEIL_KEY) */
|
|
455
|
+
rootPrivateKey: `0x${string}`;
|
|
456
|
+
/** Subaccount slot (0-2) */
|
|
457
|
+
slot: number;
|
|
458
|
+
/** Pool to merge in (default: 'eth') */
|
|
459
|
+
pool?: RelayPool;
|
|
460
|
+
/** Optional RPC URL */
|
|
461
|
+
rpcUrl?: string;
|
|
462
|
+
/** Optional relay URL */
|
|
463
|
+
relayUrl?: string;
|
|
464
|
+
/** Progress callback */
|
|
465
|
+
onProgress?: (stage: string, detail?: string) => void;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Result from merging a subaccount's balance to the main wallet
|
|
470
|
+
*/
|
|
471
|
+
export interface SubaccountMergeResult {
|
|
472
|
+
/** Whether the merge was successful */
|
|
473
|
+
success: boolean;
|
|
474
|
+
/** Transaction hash */
|
|
475
|
+
transactionHash: string;
|
|
476
|
+
/** Block number of the transaction */
|
|
477
|
+
blockNumber: string;
|
|
478
|
+
/** Amount merged (human-readable) */
|
|
479
|
+
amount: string;
|
|
480
|
+
/** Subaccount slot that was merged */
|
|
481
|
+
slot: number;
|
|
482
|
+
/** Pool the merge was executed in */
|
|
483
|
+
pool: RelayPool;
|
|
484
|
+
}
|
|
485
|
+
|
|
430
486
|
/**
|
|
431
487
|
* Built recovery transaction and signing metadata
|
|
432
488
|
*/
|