@paylobster/cli 4.2.0 → 4.3.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,1292 @@
1
+ import { Command } from 'commander';
2
+ import { type Address, formatUnits, parseUnits, decodeEventLog } from 'viem';
3
+ import { base } from 'viem/chains';
4
+ import { getWalletAddress, loadWallet } from '../lib/wallet';
5
+ import { getPublicClient } from '../lib/contracts';
6
+ import { success, error, info, withSpinner, outputJSON, confirm, createTable, formatAddress } from '../lib/display';
7
+ import chalk from 'chalk';
8
+ import type { OutputOptions } from '../lib/types';
9
+
10
+ // Treasury contracts
11
+ const TREASURY_FACTORY_ADDRESS = '0x171a685f28546a0ebb13059184db1f808b915066' as Address;
12
+ const BASE_CHAIN_ID = 8453;
13
+
14
+ // Role enum mapping
15
+ enum Role {
16
+ NONE = 0,
17
+ VIEWER = 1,
18
+ OPERATOR = 2,
19
+ ADMIN = 3,
20
+ }
21
+
22
+ const ROLE_NAMES: Record<number, string> = {
23
+ 0: 'None',
24
+ 1: 'Viewer',
25
+ 2: 'Operator',
26
+ 3: 'Admin',
27
+ };
28
+
29
+ // ABIs
30
+ const TREASURY_FACTORY_ABI = [
31
+ {
32
+ type: 'function',
33
+ name: 'createTreasury',
34
+ inputs: [{ name: '_name', type: 'string' }],
35
+ outputs: [{ name: '', type: 'address' }],
36
+ stateMutability: 'nonpayable',
37
+ },
38
+ {
39
+ type: 'function',
40
+ name: 'treasuries',
41
+ inputs: [{ name: '', type: 'address' }],
42
+ outputs: [{ name: '', type: 'address' }],
43
+ stateMutability: 'view',
44
+ },
45
+ {
46
+ type: 'event',
47
+ name: 'TreasuryCreated',
48
+ inputs: [
49
+ { name: 'owner', type: 'address', indexed: true },
50
+ { name: 'treasury', type: 'address', indexed: true },
51
+ { name: 'name', type: 'string', indexed: false },
52
+ ],
53
+ },
54
+ ] as const;
55
+
56
+ const TREASURY_ABI = [
57
+ {
58
+ type: 'function',
59
+ name: 'getSummary',
60
+ inputs: [],
61
+ outputs: [
62
+ { name: '_name', type: 'string' },
63
+ { name: '_owner', type: 'address' },
64
+ { name: 'ethBalance', type: 'uint256' },
65
+ { name: 'tokenCount', type: 'uint256' },
66
+ { name: 'memberCount', type: 'uint256' },
67
+ { name: '_totalDeposited', type: 'uint256' },
68
+ { name: '_totalWithdrawn', type: 'uint256' },
69
+ { name: '_reserveLockBps', type: 'uint256' },
70
+ { name: '_createdAt', type: 'uint256' },
71
+ ],
72
+ stateMutability: 'view',
73
+ },
74
+ {
75
+ type: 'function',
76
+ name: 'getBalances',
77
+ inputs: [],
78
+ outputs: [
79
+ {
80
+ name: '',
81
+ type: 'tuple[]',
82
+ components: [
83
+ { name: 'token', type: 'address' },
84
+ { name: 'balance', type: 'uint256' },
85
+ ],
86
+ },
87
+ ],
88
+ stateMutability: 'view',
89
+ },
90
+ {
91
+ type: 'function',
92
+ name: 'deposit',
93
+ inputs: [
94
+ { name: 'token', type: 'address' },
95
+ { name: 'amount', type: 'uint256' },
96
+ ],
97
+ outputs: [],
98
+ stateMutability: 'nonpayable',
99
+ },
100
+ {
101
+ type: 'function',
102
+ name: 'withdraw',
103
+ inputs: [
104
+ { name: 'token', type: 'address' },
105
+ { name: 'to', type: 'address' },
106
+ { name: 'amount', type: 'uint256' },
107
+ { name: 'reason', type: 'string' },
108
+ ],
109
+ outputs: [],
110
+ stateMutability: 'nonpayable',
111
+ },
112
+ {
113
+ type: 'function',
114
+ name: 'setBudget',
115
+ inputs: [
116
+ { name: 'operationsBps', type: 'uint256' },
117
+ { name: 'growthBps', type: 'uint256' },
118
+ { name: 'reservesBps', type: 'uint256' },
119
+ { name: 'yieldBps', type: 'uint256' },
120
+ ],
121
+ outputs: [],
122
+ stateMutability: 'nonpayable',
123
+ },
124
+ {
125
+ type: 'function',
126
+ name: 'budget',
127
+ inputs: [],
128
+ outputs: [
129
+ { name: 'operationsBps', type: 'uint256' },
130
+ { name: 'growthBps', type: 'uint256' },
131
+ { name: 'reservesBps', type: 'uint256' },
132
+ { name: 'yieldBps', type: 'uint256' },
133
+ ],
134
+ stateMutability: 'view',
135
+ },
136
+ {
137
+ type: 'function',
138
+ name: 'getMembers',
139
+ inputs: [],
140
+ outputs: [
141
+ { name: '', type: 'address[]' },
142
+ { name: '', type: 'uint8[]' },
143
+ ],
144
+ stateMutability: 'view',
145
+ },
146
+ {
147
+ type: 'function',
148
+ name: 'grantRole',
149
+ inputs: [
150
+ { name: 'account', type: 'address' },
151
+ { name: 'role', type: 'uint8' },
152
+ ],
153
+ outputs: [],
154
+ stateMutability: 'nonpayable',
155
+ },
156
+ {
157
+ type: 'function',
158
+ name: 'setSpendLimit',
159
+ inputs: [
160
+ { name: 'operator', type: 'address' },
161
+ { name: 'maxPerTx', type: 'uint256' },
162
+ { name: 'maxPerDay', type: 'uint256' },
163
+ ],
164
+ outputs: [],
165
+ stateMutability: 'nonpayable',
166
+ },
167
+ {
168
+ type: 'function',
169
+ name: 'spendLimits',
170
+ inputs: [{ name: '', type: 'address' }],
171
+ outputs: [
172
+ { name: 'maxPerTx', type: 'uint256' },
173
+ { name: 'maxPerDay', type: 'uint256' },
174
+ { name: 'spentToday', type: 'uint256' },
175
+ { name: 'dayStart', type: 'uint256' },
176
+ ],
177
+ stateMutability: 'view',
178
+ },
179
+ {
180
+ type: 'function',
181
+ name: 'revokeRole',
182
+ inputs: [{ name: 'account', type: 'address' }],
183
+ outputs: [],
184
+ stateMutability: 'nonpayable',
185
+ },
186
+ {
187
+ type: 'function',
188
+ name: 'setReserveLock',
189
+ inputs: [{ name: 'newLockBps', type: 'uint256' }],
190
+ outputs: [],
191
+ stateMutability: 'nonpayable',
192
+ },
193
+ {
194
+ type: 'function',
195
+ name: 'approveSpender',
196
+ inputs: [
197
+ { name: 'token', type: 'address' },
198
+ { name: 'spender', type: 'address' },
199
+ { name: 'amount', type: 'uint256' },
200
+ ],
201
+ outputs: [],
202
+ stateMutability: 'nonpayable',
203
+ },
204
+ {
205
+ type: 'function',
206
+ name: 'setIntegration',
207
+ inputs: [
208
+ { name: 'integrationName', type: 'string' },
209
+ { name: 'addr', type: 'address' },
210
+ ],
211
+ outputs: [],
212
+ stateMutability: 'nonpayable',
213
+ },
214
+ {
215
+ type: 'function',
216
+ name: 'withdrawETH',
217
+ inputs: [
218
+ { name: 'to', type: 'address' },
219
+ { name: 'amount', type: 'uint256' },
220
+ { name: 'reason', type: 'string' },
221
+ ],
222
+ outputs: [],
223
+ stateMutability: 'nonpayable',
224
+ },
225
+ ] as const;
226
+
227
+ const ERC20_ABI = [
228
+ {
229
+ type: 'function',
230
+ name: 'approve',
231
+ inputs: [
232
+ { name: 'spender', type: 'address' },
233
+ { name: 'amount', type: 'uint256' },
234
+ ],
235
+ outputs: [{ name: '', type: 'bool' }],
236
+ stateMutability: 'nonpayable',
237
+ },
238
+ {
239
+ type: 'function',
240
+ name: 'symbol',
241
+ inputs: [],
242
+ outputs: [{ name: '', type: 'string' }],
243
+ stateMutability: 'view',
244
+ },
245
+ {
246
+ type: 'function',
247
+ name: 'decimals',
248
+ inputs: [],
249
+ outputs: [{ name: '', type: 'uint8' }],
250
+ stateMutability: 'view',
251
+ },
252
+ ] as const;
253
+
254
+ /**
255
+ * Get treasury address for the connected wallet
256
+ */
257
+ async function getTreasuryAddress(address?: string): Promise<Address> {
258
+ if (address) {
259
+ return address as Address;
260
+ }
261
+
262
+ const walletAddress = getWalletAddress() as Address;
263
+ const publicClient = getPublicClient('mainnet');
264
+
265
+ const treasuryAddress = await publicClient.readContract({
266
+ address: TREASURY_FACTORY_ADDRESS,
267
+ abi: TREASURY_FACTORY_ABI,
268
+ functionName: 'treasuries',
269
+ args: [walletAddress],
270
+ });
271
+
272
+ if (treasuryAddress === '0x0000000000000000000000000000000000000000') {
273
+ throw new Error('No treasury found for connected wallet. Create one with: plob treasury create <name>');
274
+ }
275
+
276
+ return treasuryAddress;
277
+ }
278
+
279
+ /**
280
+ * Format time ago
281
+ */
282
+ function timeAgo(timestamp: bigint): string {
283
+ const now = Math.floor(Date.now() / 1000);
284
+ const secondsAgo = now - Number(timestamp);
285
+
286
+ if (secondsAgo < 60) return `${secondsAgo}s ago`;
287
+ if (secondsAgo < 3600) return `${Math.floor(secondsAgo / 60)}m ago`;
288
+ if (secondsAgo < 86400) return `${Math.floor(secondsAgo / 3600)}h ago`;
289
+ return `${Math.floor(secondsAgo / 86400)}d ago`;
290
+ }
291
+
292
+ /**
293
+ * Get token price in USD (mock for now, can integrate with price oracle)
294
+ */
295
+ async function getTokenPriceUSD(token: Address): Promise<number> {
296
+ // ETH address
297
+ if (token === '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE') {
298
+ return 3000; // Mock ETH price
299
+ }
300
+ // USDC
301
+ if (token.toLowerCase() === '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913') {
302
+ return 1;
303
+ }
304
+ // Default
305
+ return 0;
306
+ }
307
+
308
+ /**
309
+ * Parse role string to enum
310
+ */
311
+ function parseRole(roleStr: string): Role {
312
+ const normalized = roleStr.toLowerCase();
313
+ switch (normalized) {
314
+ case 'viewer':
315
+ return Role.VIEWER;
316
+ case 'operator':
317
+ return Role.OPERATOR;
318
+ case 'admin':
319
+ return Role.ADMIN;
320
+ default:
321
+ throw new Error(`Invalid role: ${roleStr}. Must be one of: viewer, operator, admin`);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Create treasury command
327
+ */
328
+ export function createTreasuryCommand(): Command {
329
+ const cmd = new Command('treasury')
330
+ .description('Manage agent treasuries on Base');
331
+
332
+ // Create treasury
333
+ cmd
334
+ .command('create')
335
+ .description('Create a new treasury')
336
+ .argument('<name>', 'Treasury name')
337
+ .option('--json', 'Output as JSON')
338
+ .action(async (name: string, options: OutputOptions) => {
339
+ try {
340
+ const { client, account } = await loadWallet();
341
+
342
+ const hash = await withSpinner(
343
+ 'Creating treasury...',
344
+ async () =>
345
+ client.writeContract({
346
+ address: TREASURY_FACTORY_ADDRESS,
347
+ abi: TREASURY_FACTORY_ABI,
348
+ functionName: 'createTreasury',
349
+ args: [name],
350
+ account,
351
+ chain: base,
352
+ })
353
+ );
354
+
355
+ // Wait for transaction and get receipt
356
+ const publicClient = getPublicClient('mainnet');
357
+ const receipt = await publicClient.waitForTransactionReceipt({ hash });
358
+
359
+ // Parse logs to get treasury address
360
+ let treasuryAddress: Address | null = null;
361
+ for (const log of receipt.logs) {
362
+ if (log.address.toLowerCase() === TREASURY_FACTORY_ADDRESS.toLowerCase()) {
363
+ try {
364
+ const decoded = decodeEventLog({
365
+ abi: TREASURY_FACTORY_ABI,
366
+ data: log.data,
367
+ topics: log.topics,
368
+ });
369
+ if (decoded.eventName === 'TreasuryCreated') {
370
+ treasuryAddress = (decoded.args as any).treasury;
371
+ break;
372
+ }
373
+ } catch {}
374
+ }
375
+ }
376
+
377
+ if (outputJSON({
378
+ name,
379
+ treasuryAddress,
380
+ transactionHash: hash,
381
+ }, options)) {
382
+ return;
383
+ }
384
+
385
+ console.log();
386
+ success('Treasury created successfully!');
387
+ console.log();
388
+ console.log(' Name: ', chalk.white.bold(name));
389
+ console.log(' Address: ', chalk.cyan(treasuryAddress || 'Check transaction receipt'));
390
+ console.log(' Tx Hash: ', chalk.gray(hash));
391
+ console.log();
392
+ info(`View on BaseScan: https://basescan.org/tx/${hash}`);
393
+ console.log();
394
+ } catch (err) {
395
+ error(`Failed to create treasury: ${err}`);
396
+ process.exit(1);
397
+ }
398
+ });
399
+
400
+ // Get treasury info
401
+ cmd
402
+ .command('info')
403
+ .description('Show treasury summary')
404
+ .argument('[address]', 'Treasury address (defaults to your treasury)')
405
+ .option('--json', 'Output as JSON')
406
+ .action(async (address: string | undefined, options: OutputOptions) => {
407
+ try {
408
+ const treasuryAddress = await getTreasuryAddress(address);
409
+ const publicClient = getPublicClient('mainnet');
410
+
411
+ const summary = await withSpinner(
412
+ 'Fetching treasury info...',
413
+ async () =>
414
+ publicClient.readContract({
415
+ address: treasuryAddress,
416
+ abi: TREASURY_ABI,
417
+ functionName: 'getSummary',
418
+ })
419
+ );
420
+
421
+ const [name, owner, ethBalance, tokenCount, memberCount, totalDeposited, totalWithdrawn, reserveLockBps, createdAt] = summary;
422
+
423
+ if (outputJSON({
424
+ address: treasuryAddress,
425
+ name,
426
+ owner,
427
+ ethBalance: ethBalance.toString(),
428
+ tokenCount: Number(tokenCount),
429
+ memberCount: Number(memberCount),
430
+ totalDeposited: totalDeposited.toString(),
431
+ totalWithdrawn: totalWithdrawn.toString(),
432
+ reserveLockBps: Number(reserveLockBps),
433
+ createdAt: Number(createdAt),
434
+ age: timeAgo(createdAt),
435
+ }, options)) {
436
+ return;
437
+ }
438
+
439
+ console.log();
440
+ console.log(chalk.bold.cyan('🏛️ Treasury Summary'));
441
+ console.log();
442
+ console.log(' Name: ', chalk.white.bold(name));
443
+ console.log(' Address: ', chalk.gray(treasuryAddress));
444
+ console.log(' Owner: ', chalk.gray(owner));
445
+ console.log(' ETH Balance: ', chalk.white(formatUnits(ethBalance, 18)), chalk.gray('ETH'));
446
+ console.log(' Token Count: ', chalk.white(tokenCount.toString()));
447
+ console.log(' Members: ', chalk.white(memberCount.toString()));
448
+ console.log(' Total Deposited:', chalk.green(`$${formatUnits(totalDeposited, 6)}`));
449
+ console.log(' Total Withdrawn:', chalk.yellow(`$${formatUnits(totalWithdrawn, 6)}`));
450
+ console.log(' Reserve Lock: ', chalk.white(`${Number(reserveLockBps) / 100}%`));
451
+ console.log(' Age: ', chalk.gray(timeAgo(createdAt)));
452
+ console.log();
453
+ } catch (err) {
454
+ error(`Failed to get treasury info: ${err}`);
455
+ process.exit(1);
456
+ }
457
+ });
458
+
459
+ // Get balances
460
+ cmd
461
+ .command('balances')
462
+ .description('Show all token balances')
463
+ .argument('[address]', 'Treasury address (defaults to your treasury)')
464
+ .option('--json', 'Output as JSON')
465
+ .action(async (address: string | undefined, options: OutputOptions) => {
466
+ try {
467
+ const treasuryAddress = await getTreasuryAddress(address);
468
+ const publicClient = getPublicClient('mainnet');
469
+
470
+ const balances = await withSpinner(
471
+ 'Fetching balances...',
472
+ async () =>
473
+ publicClient.readContract({
474
+ address: treasuryAddress,
475
+ abi: TREASURY_ABI,
476
+ functionName: 'getBalances',
477
+ })
478
+ );
479
+
480
+ // Get token symbols and decimals
481
+ const enrichedBalances = await Promise.all(
482
+ balances.map(async (bal) => {
483
+ try {
484
+ const [symbol, decimals] = await Promise.all([
485
+ publicClient.readContract({
486
+ address: bal.token,
487
+ abi: ERC20_ABI,
488
+ functionName: 'symbol',
489
+ }),
490
+ publicClient.readContract({
491
+ address: bal.token,
492
+ abi: ERC20_ABI,
493
+ functionName: 'decimals',
494
+ }),
495
+ ]);
496
+
497
+ const amount = formatUnits(bal.balance, decimals);
498
+ const priceUSD = await getTokenPriceUSD(bal.token);
499
+ const valueUSD = parseFloat(amount) * priceUSD;
500
+
501
+ return {
502
+ token: bal.token,
503
+ symbol,
504
+ balance: amount,
505
+ decimals,
506
+ valueUSD,
507
+ };
508
+ } catch {
509
+ return {
510
+ token: bal.token,
511
+ symbol: 'UNKNOWN',
512
+ balance: bal.balance.toString(),
513
+ decimals: 18,
514
+ valueUSD: 0,
515
+ };
516
+ }
517
+ })
518
+ );
519
+
520
+ if (outputJSON({ balances: enrichedBalances }, options)) {
521
+ return;
522
+ }
523
+
524
+ console.log();
525
+ console.log(chalk.bold.cyan('💰 Treasury Balances'));
526
+ console.log();
527
+
528
+ if (enrichedBalances.length === 0) {
529
+ info('No token balances');
530
+ return;
531
+ }
532
+
533
+ const table = createTable({
534
+ head: ['Token', 'Balance', 'Value (USD)'],
535
+ });
536
+
537
+ for (const bal of enrichedBalances) {
538
+ table.push([
539
+ chalk.white.bold(bal.symbol),
540
+ chalk.white(bal.balance),
541
+ chalk.green(`$${bal.valueUSD.toFixed(2)}`),
542
+ ]);
543
+ }
544
+
545
+ console.log(table.toString());
546
+ console.log();
547
+
548
+ const totalValue = enrichedBalances.reduce((sum, bal) => sum + bal.valueUSD, 0);
549
+ console.log(' Total Value: ', chalk.bold.green(`$${totalValue.toFixed(2)}`));
550
+ console.log();
551
+ } catch (err) {
552
+ error(`Failed to get balances: ${err}`);
553
+ process.exit(1);
554
+ }
555
+ });
556
+
557
+ // Deposit
558
+ cmd
559
+ .command('deposit')
560
+ .description('Deposit tokens into treasury')
561
+ .requiredOption('--token <address>', 'Token address')
562
+ .requiredOption('--amount <amount>', 'Amount to deposit')
563
+ .option('--json', 'Output as JSON')
564
+ .action(async (options: {
565
+ token: string;
566
+ amount: string;
567
+ } & OutputOptions) => {
568
+ try {
569
+ const treasuryAddress = await getTreasuryAddress();
570
+ const { client, account } = await loadWallet();
571
+ const publicClient = getPublicClient('mainnet');
572
+
573
+ const tokenAddress = options.token as Address;
574
+
575
+ // Get token decimals
576
+ const decimals = await publicClient.readContract({
577
+ address: tokenAddress,
578
+ abi: ERC20_ABI,
579
+ functionName: 'decimals',
580
+ });
581
+
582
+ const amount = parseUnits(options.amount, decimals);
583
+
584
+ // First approve token
585
+ const approveHash = await withSpinner(
586
+ 'Approving token...',
587
+ async () =>
588
+ client.writeContract({
589
+ address: tokenAddress,
590
+ abi: ERC20_ABI,
591
+ functionName: 'approve',
592
+ args: [treasuryAddress, amount],
593
+ account,
594
+ chain: base,
595
+ })
596
+ );
597
+
598
+ await publicClient.waitForTransactionReceipt({ hash: approveHash });
599
+
600
+ // Deposit
601
+ const depositHash = await withSpinner(
602
+ 'Depositing tokens...',
603
+ async () =>
604
+ client.writeContract({
605
+ address: treasuryAddress,
606
+ abi: TREASURY_ABI,
607
+ functionName: 'deposit',
608
+ args: [tokenAddress, amount],
609
+ account,
610
+ chain: base,
611
+ })
612
+ );
613
+
614
+ await publicClient.waitForTransactionReceipt({ hash: depositHash });
615
+
616
+ if (outputJSON({
617
+ token: tokenAddress,
618
+ amount: options.amount,
619
+ transactionHash: depositHash,
620
+ }, options)) {
621
+ return;
622
+ }
623
+
624
+ console.log();
625
+ success('Deposit successful!');
626
+ console.log();
627
+ console.log(' Amount: ', chalk.white.bold(options.amount));
628
+ console.log(' Token: ', chalk.gray(tokenAddress));
629
+ console.log(' Tx Hash: ', chalk.gray(depositHash));
630
+ console.log();
631
+ } catch (err) {
632
+ error(`Failed to deposit: ${err}`);
633
+ process.exit(1);
634
+ }
635
+ });
636
+
637
+ // Withdraw
638
+ cmd
639
+ .command('withdraw')
640
+ .description('Withdraw tokens from treasury')
641
+ .requiredOption('--token <address>', 'Token address')
642
+ .requiredOption('--to <address>', 'Recipient address')
643
+ .requiredOption('--amount <amount>', 'Amount to withdraw')
644
+ .requiredOption('--reason <string>', 'Reason for withdrawal')
645
+ .option('--json', 'Output as JSON')
646
+ .action(async (options: {
647
+ token: string;
648
+ to: string;
649
+ amount: string;
650
+ reason: string;
651
+ } & OutputOptions) => {
652
+ try {
653
+ const treasuryAddress = await getTreasuryAddress();
654
+ const { client, account } = await loadWallet();
655
+ const publicClient = getPublicClient('mainnet');
656
+
657
+ const tokenAddress = options.token as Address;
658
+ const toAddress = options.to as Address;
659
+
660
+ // Get token decimals
661
+ const decimals = await publicClient.readContract({
662
+ address: tokenAddress,
663
+ abi: ERC20_ABI,
664
+ functionName: 'decimals',
665
+ });
666
+
667
+ const amount = parseUnits(options.amount, decimals);
668
+
669
+ const hash = await withSpinner(
670
+ 'Withdrawing tokens...',
671
+ async () =>
672
+ client.writeContract({
673
+ address: treasuryAddress,
674
+ abi: TREASURY_ABI,
675
+ functionName: 'withdraw',
676
+ args: [tokenAddress, toAddress, amount, options.reason],
677
+ account,
678
+ chain: base,
679
+ })
680
+ );
681
+
682
+ await publicClient.waitForTransactionReceipt({ hash });
683
+
684
+ if (outputJSON({
685
+ token: tokenAddress,
686
+ to: toAddress,
687
+ amount: options.amount,
688
+ reason: options.reason,
689
+ transactionHash: hash,
690
+ }, options)) {
691
+ return;
692
+ }
693
+
694
+ console.log();
695
+ success('Withdrawal successful!');
696
+ console.log();
697
+ console.log(' Amount: ', chalk.white.bold(options.amount));
698
+ console.log(' To: ', chalk.gray(toAddress));
699
+ console.log(' Reason: ', chalk.white(options.reason));
700
+ console.log(' Tx Hash: ', chalk.gray(hash));
701
+ console.log();
702
+ } catch (err) {
703
+ error(`Failed to withdraw: ${err}`);
704
+ process.exit(1);
705
+ }
706
+ });
707
+
708
+ // Set budget
709
+ cmd
710
+ .command('budget')
711
+ .description('Set budget allocation (must total 10000 bps)')
712
+ .requiredOption('--ops <bps>', 'Operations allocation in basis points')
713
+ .requiredOption('--growth <bps>', 'Growth allocation in basis points')
714
+ .requiredOption('--reserves <bps>', 'Reserves allocation in basis points')
715
+ .requiredOption('--yield <bps>', 'Yield allocation in basis points')
716
+ .option('--json', 'Output as JSON')
717
+ .action(async (options: {
718
+ ops: string;
719
+ growth: string;
720
+ reserves: string;
721
+ yield: string;
722
+ } & OutputOptions) => {
723
+ try {
724
+ const opsBps = BigInt(options.ops);
725
+ const growthBps = BigInt(options.growth);
726
+ const reservesBps = BigInt(options.reserves);
727
+ const yieldBps = BigInt(options.yield);
728
+
729
+ const total = opsBps + growthBps + reservesBps + yieldBps;
730
+ if (total !== 10000n) {
731
+ throw new Error(`Budget allocations must total 10000 bps. Got: ${total}`);
732
+ }
733
+
734
+ const treasuryAddress = await getTreasuryAddress();
735
+ const { client, account } = await loadWallet();
736
+ const publicClient = getPublicClient('mainnet');
737
+
738
+ const hash = await withSpinner(
739
+ 'Setting budget allocation...',
740
+ async () =>
741
+ client.writeContract({
742
+ address: treasuryAddress,
743
+ abi: TREASURY_ABI,
744
+ functionName: 'setBudget',
745
+ args: [opsBps, growthBps, reservesBps, yieldBps],
746
+ account,
747
+ chain: base,
748
+ })
749
+ );
750
+
751
+ await publicClient.waitForTransactionReceipt({ hash });
752
+
753
+ if (outputJSON({
754
+ operations: Number(opsBps),
755
+ growth: Number(growthBps),
756
+ reserves: Number(reservesBps),
757
+ yield: Number(yieldBps),
758
+ transactionHash: hash,
759
+ }, options)) {
760
+ return;
761
+ }
762
+
763
+ console.log();
764
+ success('Budget allocation updated!');
765
+ console.log();
766
+ console.log(' Operations: ', chalk.white(`${Number(opsBps) / 100}%`));
767
+ console.log(' Growth: ', chalk.white(`${Number(growthBps) / 100}%`));
768
+ console.log(' Reserves: ', chalk.white(`${Number(reservesBps) / 100}%`));
769
+ console.log(' Yield: ', chalk.white(`${Number(yieldBps) / 100}%`));
770
+ console.log();
771
+ } catch (err) {
772
+ error(`Failed to set budget: ${err}`);
773
+ process.exit(1);
774
+ }
775
+ });
776
+
777
+ // List members
778
+ cmd
779
+ .command('members')
780
+ .description('List all treasury members with roles')
781
+ .argument('[address]', 'Treasury address (defaults to your treasury)')
782
+ .option('--json', 'Output as JSON')
783
+ .action(async (address: string | undefined, options: OutputOptions) => {
784
+ try {
785
+ const treasuryAddress = await getTreasuryAddress(address);
786
+ const publicClient = getPublicClient('mainnet');
787
+
788
+ const [addresses, roles] = await withSpinner(
789
+ 'Fetching members...',
790
+ async () =>
791
+ publicClient.readContract({
792
+ address: treasuryAddress,
793
+ abi: TREASURY_ABI,
794
+ functionName: 'getMembers',
795
+ })
796
+ );
797
+
798
+ const members = addresses.map((addr, i) => ({
799
+ address: addr,
800
+ role: ROLE_NAMES[roles[i]] || 'Unknown',
801
+ roleValue: roles[i],
802
+ }));
803
+
804
+ if (outputJSON({ members }, options)) {
805
+ return;
806
+ }
807
+
808
+ console.log();
809
+ console.log(chalk.bold.cyan('👥 Treasury Members'));
810
+ console.log();
811
+
812
+ if (members.length === 0) {
813
+ info('No members found');
814
+ return;
815
+ }
816
+
817
+ const table = createTable({
818
+ head: ['Address', 'Role'],
819
+ });
820
+
821
+ for (const member of members) {
822
+ const roleColor = member.roleValue === 3 ? chalk.red : member.roleValue === 2 ? chalk.yellow : chalk.blue;
823
+ table.push([
824
+ chalk.gray(member.address),
825
+ roleColor(member.role),
826
+ ]);
827
+ }
828
+
829
+ console.log(table.toString());
830
+ console.log();
831
+ } catch (err) {
832
+ error(`Failed to get members: ${err}`);
833
+ process.exit(1);
834
+ }
835
+ });
836
+
837
+ // Grant role
838
+ cmd
839
+ .command('grant')
840
+ .description('Grant role to an address')
841
+ .requiredOption('--address <addr>', 'Address to grant role')
842
+ .requiredOption('--role <role>', 'Role to grant (viewer|operator|admin)')
843
+ .option('--json', 'Output as JSON')
844
+ .action(async (options: {
845
+ address: string;
846
+ role: string;
847
+ } & OutputOptions) => {
848
+ try {
849
+ const roleEnum = parseRole(options.role);
850
+ const memberAddress = options.address as Address;
851
+ const treasuryAddress = await getTreasuryAddress();
852
+ const { client, account } = await loadWallet();
853
+ const publicClient = getPublicClient('mainnet');
854
+
855
+ const hash = await withSpinner(
856
+ 'Granting role...',
857
+ async () =>
858
+ client.writeContract({
859
+ address: treasuryAddress,
860
+ abi: TREASURY_ABI,
861
+ functionName: 'grantRole',
862
+ args: [memberAddress, roleEnum],
863
+ account,
864
+ chain: base,
865
+ })
866
+ );
867
+
868
+ await publicClient.waitForTransactionReceipt({ hash });
869
+
870
+ if (outputJSON({
871
+ address: memberAddress,
872
+ role: options.role,
873
+ transactionHash: hash,
874
+ }, options)) {
875
+ return;
876
+ }
877
+
878
+ console.log();
879
+ success('Role granted successfully!');
880
+ console.log();
881
+ console.log(' Address: ', chalk.gray(memberAddress));
882
+ console.log(' Role: ', chalk.white.bold(options.role));
883
+ console.log(' Tx Hash: ', chalk.gray(hash));
884
+ console.log();
885
+ } catch (err) {
886
+ error(`Failed to grant role: ${err}`);
887
+ process.exit(1);
888
+ }
889
+ });
890
+
891
+ // Set spend limit
892
+ cmd
893
+ .command('limit')
894
+ .description('Set spending limits for an operator')
895
+ .requiredOption('--address <addr>', 'Operator address')
896
+ .requiredOption('--per-tx <amount>', 'Max amount per transaction (in USD)')
897
+ .requiredOption('--per-day <amount>', 'Max amount per day (in USD)')
898
+ .option('--json', 'Output as JSON')
899
+ .action(async (options: {
900
+ address: string;
901
+ perTx: string;
902
+ perDay: string;
903
+ } & OutputOptions) => {
904
+ try {
905
+ const operatorAddress = options.address as Address;
906
+ const treasuryAddress = await getTreasuryAddress();
907
+ const { client, account } = await loadWallet();
908
+ const publicClient = getPublicClient('mainnet');
909
+
910
+ // Convert USD amounts to USDC wei (6 decimals)
911
+ const maxPerTx = parseUnits(options.perTx, 6);
912
+ const maxPerDay = parseUnits(options.perDay, 6);
913
+
914
+ const hash = await withSpinner(
915
+ 'Setting spend limits...',
916
+ async () =>
917
+ client.writeContract({
918
+ address: treasuryAddress,
919
+ abi: TREASURY_ABI,
920
+ functionName: 'setSpendLimit',
921
+ args: [operatorAddress, maxPerTx, maxPerDay],
922
+ account,
923
+ chain: base,
924
+ })
925
+ );
926
+
927
+ await publicClient.waitForTransactionReceipt({ hash });
928
+
929
+ if (outputJSON({
930
+ operator: operatorAddress,
931
+ maxPerTx: options.perTx,
932
+ maxPerDay: options.perDay,
933
+ transactionHash: hash,
934
+ }, options)) {
935
+ return;
936
+ }
937
+
938
+ console.log();
939
+ success('Spend limits set successfully!');
940
+ console.log();
941
+ console.log(' Operator: ', chalk.gray(operatorAddress));
942
+ console.log(' Max Per Tx: ', chalk.white.bold(`$${options.perTx}`));
943
+ console.log(' Max Per Day: ', chalk.white.bold(`$${options.perDay}`));
944
+ console.log(' Tx Hash: ', chalk.gray(hash));
945
+ console.log();
946
+ } catch (err) {
947
+ error(`Failed to set spend limits: ${err}`);
948
+ process.exit(1);
949
+ }
950
+ });
951
+
952
+ // Revoke role
953
+ cmd
954
+ .command('revoke')
955
+ .description('Revoke role from an address')
956
+ .requiredOption('--address <addr>', 'Address to revoke role from')
957
+ .option('--json', 'Output as JSON')
958
+ .action(async (options: {
959
+ address: string;
960
+ } & OutputOptions) => {
961
+ try {
962
+ const accountAddress = options.address as Address;
963
+ const treasuryAddress = await getTreasuryAddress();
964
+ const { client, account } = await loadWallet();
965
+ const publicClient = getPublicClient('mainnet');
966
+
967
+ const hash = await withSpinner(
968
+ 'Revoking role...',
969
+ async () =>
970
+ client.writeContract({
971
+ address: treasuryAddress,
972
+ abi: TREASURY_ABI,
973
+ functionName: 'revokeRole',
974
+ args: [accountAddress],
975
+ account,
976
+ chain: base,
977
+ })
978
+ );
979
+
980
+ await publicClient.waitForTransactionReceipt({ hash });
981
+
982
+ if (outputJSON({
983
+ address: accountAddress,
984
+ transactionHash: hash,
985
+ }, options)) {
986
+ return;
987
+ }
988
+
989
+ console.log();
990
+ success('Role revoked successfully!');
991
+ console.log();
992
+ console.log(' Address: ', chalk.gray(accountAddress));
993
+ console.log(' Tx Hash: ', chalk.gray(hash));
994
+ console.log();
995
+ } catch (err) {
996
+ error(`Failed to revoke role: ${err}`);
997
+ process.exit(1);
998
+ }
999
+ });
1000
+
1001
+ // Set reserve lock
1002
+ cmd
1003
+ .command('reserve')
1004
+ .description('Set reserve lock percentage (0-5000 bps)')
1005
+ .requiredOption('--lock <bps>', 'Reserve lock in basis points (0-5000)')
1006
+ .option('--json', 'Output as JSON')
1007
+ .action(async (options: {
1008
+ lock: string;
1009
+ } & OutputOptions) => {
1010
+ try {
1011
+ const lockBps = BigInt(options.lock);
1012
+ if (lockBps < 0n || lockBps > 5000n) {
1013
+ throw new Error('Reserve lock must be between 0 and 5000 bps (0-50%)');
1014
+ }
1015
+
1016
+ const treasuryAddress = await getTreasuryAddress();
1017
+ const { client, account } = await loadWallet();
1018
+ const publicClient = getPublicClient('mainnet');
1019
+
1020
+ const hash = await withSpinner(
1021
+ 'Setting reserve lock...',
1022
+ async () =>
1023
+ client.writeContract({
1024
+ address: treasuryAddress,
1025
+ abi: TREASURY_ABI,
1026
+ functionName: 'setReserveLock',
1027
+ args: [lockBps],
1028
+ account,
1029
+ chain: base,
1030
+ })
1031
+ );
1032
+
1033
+ await publicClient.waitForTransactionReceipt({ hash });
1034
+
1035
+ if (outputJSON({
1036
+ lockBps: Number(lockBps),
1037
+ lockPercent: Number(lockBps) / 100,
1038
+ transactionHash: hash,
1039
+ }, options)) {
1040
+ return;
1041
+ }
1042
+
1043
+ console.log();
1044
+ success('Reserve lock set successfully!');
1045
+ console.log();
1046
+ console.log(' Lock: ', chalk.white.bold(`${Number(lockBps) / 100}%`));
1047
+ console.log(' Tx Hash: ', chalk.gray(hash));
1048
+ console.log();
1049
+ } catch (err) {
1050
+ error(`Failed to set reserve lock: ${err}`);
1051
+ process.exit(1);
1052
+ }
1053
+ });
1054
+
1055
+ // Approve spender
1056
+ cmd
1057
+ .command('approve')
1058
+ .description('Approve token spender')
1059
+ .requiredOption('--token <addr>', 'Token address')
1060
+ .requiredOption('--spender <addr>', 'Spender address')
1061
+ .requiredOption('--amount <amount>', 'Amount to approve')
1062
+ .option('--json', 'Output as JSON')
1063
+ .action(async (options: {
1064
+ token: string;
1065
+ spender: string;
1066
+ amount: string;
1067
+ } & OutputOptions) => {
1068
+ try {
1069
+ const tokenAddress = options.token as Address;
1070
+ const spenderAddress = options.spender as Address;
1071
+ const treasuryAddress = await getTreasuryAddress();
1072
+ const { client, account } = await loadWallet();
1073
+ const publicClient = getPublicClient('mainnet');
1074
+
1075
+ // Get token decimals
1076
+ const decimals = await publicClient.readContract({
1077
+ address: tokenAddress,
1078
+ abi: ERC20_ABI,
1079
+ functionName: 'decimals',
1080
+ });
1081
+
1082
+ const amount = parseUnits(options.amount, decimals);
1083
+
1084
+ const hash = await withSpinner(
1085
+ 'Approving spender...',
1086
+ async () =>
1087
+ client.writeContract({
1088
+ address: treasuryAddress,
1089
+ abi: TREASURY_ABI,
1090
+ functionName: 'approveSpender',
1091
+ args: [tokenAddress, spenderAddress, amount],
1092
+ account,
1093
+ chain: base,
1094
+ })
1095
+ );
1096
+
1097
+ await publicClient.waitForTransactionReceipt({ hash });
1098
+
1099
+ if (outputJSON({
1100
+ token: tokenAddress,
1101
+ spender: spenderAddress,
1102
+ amount: options.amount,
1103
+ transactionHash: hash,
1104
+ }, options)) {
1105
+ return;
1106
+ }
1107
+
1108
+ console.log();
1109
+ success('Spender approved successfully!');
1110
+ console.log();
1111
+ console.log(' Token: ', chalk.gray(tokenAddress));
1112
+ console.log(' Spender: ', chalk.gray(spenderAddress));
1113
+ console.log(' Amount: ', chalk.white.bold(options.amount));
1114
+ console.log(' Tx Hash: ', chalk.gray(hash));
1115
+ console.log();
1116
+ } catch (err) {
1117
+ error(`Failed to approve spender: ${err}`);
1118
+ process.exit(1);
1119
+ }
1120
+ });
1121
+
1122
+ // Set integration
1123
+ cmd
1124
+ .command('integrate')
1125
+ .description('Set integration contract')
1126
+ .requiredOption('--name <name>', 'Integration name (spendingMandate|policyRegistry|crossRailLedger)')
1127
+ .requiredOption('--address <addr>', 'Integration contract address')
1128
+ .option('--json', 'Output as JSON')
1129
+ .action(async (options: {
1130
+ name: string;
1131
+ address: string;
1132
+ } & OutputOptions) => {
1133
+ try {
1134
+ const validNames = ['spendingMandate', 'policyRegistry', 'crossRailLedger'];
1135
+ if (!validNames.includes(options.name)) {
1136
+ throw new Error(`Invalid integration name. Must be one of: ${validNames.join(', ')}`);
1137
+ }
1138
+
1139
+ const integrationAddress = options.address as Address;
1140
+ const treasuryAddress = await getTreasuryAddress();
1141
+ const { client, account } = await loadWallet();
1142
+ const publicClient = getPublicClient('mainnet');
1143
+
1144
+ const hash = await withSpinner(
1145
+ 'Setting integration...',
1146
+ async () =>
1147
+ client.writeContract({
1148
+ address: treasuryAddress,
1149
+ abi: TREASURY_ABI,
1150
+ functionName: 'setIntegration',
1151
+ args: [options.name, integrationAddress],
1152
+ account,
1153
+ chain: base,
1154
+ })
1155
+ );
1156
+
1157
+ await publicClient.waitForTransactionReceipt({ hash });
1158
+
1159
+ if (outputJSON({
1160
+ name: options.name,
1161
+ address: integrationAddress,
1162
+ transactionHash: hash,
1163
+ }, options)) {
1164
+ return;
1165
+ }
1166
+
1167
+ console.log();
1168
+ success('Integration set successfully!');
1169
+ console.log();
1170
+ console.log(' Name: ', chalk.white.bold(options.name));
1171
+ console.log(' Address: ', chalk.gray(integrationAddress));
1172
+ console.log(' Tx Hash: ', chalk.gray(hash));
1173
+ console.log();
1174
+ } catch (err) {
1175
+ error(`Failed to set integration: ${err}`);
1176
+ process.exit(1);
1177
+ }
1178
+ });
1179
+
1180
+ // Withdraw ETH
1181
+ cmd
1182
+ .command('withdraw-eth')
1183
+ .description('Withdraw ETH from treasury')
1184
+ .requiredOption('--to <addr>', 'Recipient address')
1185
+ .requiredOption('--amount <amount>', 'Amount in ETH')
1186
+ .requiredOption('--reason <string>', 'Withdrawal reason')
1187
+ .option('--json', 'Output as JSON')
1188
+ .action(async (options: {
1189
+ to: string;
1190
+ amount: string;
1191
+ reason: string;
1192
+ } & OutputOptions) => {
1193
+ try {
1194
+ const toAddress = options.to as Address;
1195
+ const treasuryAddress = await getTreasuryAddress();
1196
+ const { client, account } = await loadWallet();
1197
+ const publicClient = getPublicClient('mainnet');
1198
+
1199
+ const amount = parseUnits(options.amount, 18);
1200
+
1201
+ const hash = await withSpinner(
1202
+ 'Withdrawing ETH...',
1203
+ async () =>
1204
+ client.writeContract({
1205
+ address: treasuryAddress,
1206
+ abi: TREASURY_ABI,
1207
+ functionName: 'withdrawETH',
1208
+ args: [toAddress, amount, options.reason],
1209
+ account,
1210
+ chain: base,
1211
+ })
1212
+ );
1213
+
1214
+ await publicClient.waitForTransactionReceipt({ hash });
1215
+
1216
+ if (outputJSON({
1217
+ to: toAddress,
1218
+ amount: options.amount,
1219
+ reason: options.reason,
1220
+ transactionHash: hash,
1221
+ }, options)) {
1222
+ return;
1223
+ }
1224
+
1225
+ console.log();
1226
+ success('ETH withdrawn successfully!');
1227
+ console.log();
1228
+ console.log(' Amount: ', chalk.white.bold(`${options.amount} ETH`));
1229
+ console.log(' To: ', chalk.gray(toAddress));
1230
+ console.log(' Reason: ', chalk.white(options.reason));
1231
+ console.log(' Tx Hash: ', chalk.gray(hash));
1232
+ console.log();
1233
+ } catch (err) {
1234
+ error(`Failed to withdraw ETH: ${err}`);
1235
+ process.exit(1);
1236
+ }
1237
+ });
1238
+
1239
+ // Get spend limits
1240
+ cmd
1241
+ .command('limits')
1242
+ .description('View spend limits for an operator')
1243
+ .requiredOption('--address <addr>', 'Operator address')
1244
+ .option('--json', 'Output as JSON')
1245
+ .action(async (address: string, options: OutputOptions & { address: string }) => {
1246
+ try {
1247
+ const operatorAddress = options.address as Address;
1248
+ const treasuryAddress = await getTreasuryAddress();
1249
+ const publicClient = getPublicClient('mainnet');
1250
+
1251
+ const limits = await withSpinner(
1252
+ 'Fetching spend limits...',
1253
+ async () =>
1254
+ publicClient.readContract({
1255
+ address: treasuryAddress,
1256
+ abi: TREASURY_ABI,
1257
+ functionName: 'spendLimits',
1258
+ args: [operatorAddress],
1259
+ })
1260
+ );
1261
+
1262
+ const [maxPerTx, maxPerDay, spentToday, dayStart] = limits;
1263
+
1264
+ if (outputJSON({
1265
+ operator: operatorAddress,
1266
+ maxPerTx: formatUnits(maxPerTx, 6),
1267
+ maxPerDay: formatUnits(maxPerDay, 6),
1268
+ spentToday: formatUnits(spentToday, 6),
1269
+ dayStart: Number(dayStart),
1270
+ remainingToday: formatUnits(maxPerDay - spentToday, 6),
1271
+ }, options)) {
1272
+ return;
1273
+ }
1274
+
1275
+ console.log();
1276
+ console.log(chalk.bold.cyan('💳 Spend Limits'));
1277
+ console.log();
1278
+ console.log(' Operator: ', chalk.gray(operatorAddress));
1279
+ console.log(' Max Per Tx: ', chalk.white.bold(`$${formatUnits(maxPerTx, 6)}`));
1280
+ console.log(' Max Per Day: ', chalk.white.bold(`$${formatUnits(maxPerDay, 6)}`));
1281
+ console.log(' Spent Today: ', chalk.yellow(`$${formatUnits(spentToday, 6)}`));
1282
+ console.log(' Remaining Today: ', chalk.green(`$${formatUnits(maxPerDay - spentToday, 6)}`));
1283
+ console.log(' Day Start: ', chalk.gray(new Date(Number(dayStart) * 1000).toLocaleString()));
1284
+ console.log();
1285
+ } catch (err) {
1286
+ error(`Failed to get spend limits: ${err}`);
1287
+ process.exit(1);
1288
+ }
1289
+ });
1290
+
1291
+ return cmd;
1292
+ }