botanary 0.3.0 → 0.4.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.
@@ -1,5 +1,7 @@
1
1
  import * as clack from '@clack/prompts';
2
- import { fetchComposite, walletApiGet, runLogin, openBrowser, portfolioFreshness, walletSend, resolveMandateForSend, signUnderMandateReal, createSignRequestReal, getSignRequestReal, preflightReal, scopedToAccount, resolveAsset, baseUnits, GET_ROUTES, } from 'botanary-mcp';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { fetchComposite, walletApiGet, runLogin, openBrowser, portfolioFreshness, resolveMandateForSend, signUnderMandateReal, preflightReal, scopedToAccount, resolveAsset, baseUnits, GET_ROUTES, } from 'botanary-mcp';
4
+ import { submitIntent, describeSignOutcome } from './sign.js';
3
5
  import { colors, glyphs } from '../render/colors.js';
4
6
  import { renderTable } from '../render/table.js';
5
7
  import { redactForDisplay } from '../render/redact.js';
@@ -20,8 +22,13 @@ import { assertAddress, assertTierAllows, familyOf, findChain, isTokenRef, parse
20
22
  import { CliError, EXIT, invalidArgs, notLoggedIn } from '../errors.js';
21
23
  import { appUrl } from '../app-url.js';
22
24
  /** A WalletSession carries `sessionToken` - redact it in any printed output unless --reveal, the same
23
- * redact-by-default rule the design spec commits to everywhere. Anything that prints a session (status,
24
- * accounts use) must route through this rather than printResult directly. */
25
+ * redact-by-default rule the design spec commits to everywhere. Anything that prints a session (login,
26
+ * status, whoami, accounts use) must route through this rather than printResult - or a hand-rolled
27
+ * `console.log(JSON.stringify(...))` - directly.
28
+ *
29
+ * That is not a style preference. `login --json` hand-rolled its own print and shipped the owner's live
30
+ * bearer to stdout in 0.3.0 - and `--json` is the PIPE path, so it landed in CI logs, shell history and
31
+ * anything downstream of the pipe. One printer means one place to get this right. */
25
32
  function printSessionResult(ctx, data) {
26
33
  const session = data?.session ?? data;
27
34
  const token = session?.sessionToken;
@@ -34,6 +41,35 @@ async function requireWalletToken(ctx) {
34
41
  }
35
42
  return session.sessionToken;
36
43
  }
44
+ /** Same shape as `account.ts`/`authority.ts`/`services.ts`'s own `reviewAndSubmit` - duplicated per
45
+ * `sign.ts`'s own comment on why a shared helper never gets pulled back out of that file. */
46
+ async function reviewAndSubmit(ctx, title, rows, warnings, kind, params) {
47
+ if (ctx.dryRun) {
48
+ printResult(ctx, dryRunPayload(title, rows, warnings), () => renderReview('Dry run - nothing will be sent', rows, warnings));
49
+ return;
50
+ }
51
+ await confirmOrThrow(ctx, title, rows, warnings);
52
+ const outcome = await submitIntent(ctx, kind, params);
53
+ printResult(ctx, outcome.data, () => describeSignOutcome(outcome));
54
+ }
55
+ /** Read and parse a JSON file for a complex, arbitrarily-sized intent field - the same escape hatch
56
+ * `agent request --calls-file` already established, rather than a wall of flags for a deeply nested
57
+ * shape (a delegation grant's budgets, a mandate confirm's candidates). */
58
+ async function readJsonFile(path, flag) {
59
+ let raw;
60
+ try {
61
+ raw = await readFile(path, 'utf8');
62
+ }
63
+ catch (e) {
64
+ throw invalidArgs(`Could not read ${flag} "${path}": ${e instanceof Error ? e.message : String(e)}`);
65
+ }
66
+ try {
67
+ return JSON.parse(raw);
68
+ }
69
+ catch (e) {
70
+ throw invalidArgs(`${flag} "${path}" is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
71
+ }
72
+ }
37
73
  export function registerWalletCommands(program) {
38
74
  group(program
39
75
  .command('login')
@@ -61,7 +97,10 @@ export function registerWalletCommands(program) {
61
97
  });
62
98
  if (result.status === 'authorized') {
63
99
  if (ctx.json) {
64
- console.log(JSON.stringify({ status: 'authorized', session: result.session ?? null }));
100
+ // Same payload as 0.3.0, routed through the one redaction path instead of a second
101
+ // hand-rolled JSON.stringify - see printSessionResult's header. --reveal is still the
102
+ // deliberate opt-in for an owner who actually wants the token; the default never prints it.
103
+ printSessionResult(ctx, { status: 'authorized', session: result.session ?? null });
65
104
  }
66
105
  else {
67
106
  console.log(glyphs.success(`Logged in${result.session ? ` (account ${result.session.accountId ?? 'default'})` : ''}`));
@@ -301,36 +340,116 @@ export function registerWalletCommands(program) {
301
340
  return;
302
341
  }
303
342
  await confirmOrThrow(ctx, 'Review this payment', rows, warnings);
304
- const result = await step(ctx, lane === 'mandate' ? 'Signing under your mandate' : 'Waiting for your approval', () => walletSend({
305
- mandateAccountId: mandate.accountId,
306
- mandateInBounds: mandate.inBounds,
307
- mandateBoundsReason: mandate.reason,
308
- walletAccountId: session?.accountId ?? '',
309
- signUnderMandate: (i) => signUnderMandateReal(ctx.runtime, mandate.grant, i),
310
- createSignRequest: createSignRequestReal(ctx.runtime, walletToken),
311
- getSignRequest: getSignRequestReal(ctx.runtime, walletToken),
312
- openBrowser,
313
- now: () => Date.now(),
314
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
315
- preflight: preflightReal(ctx.runtime, walletToken),
316
- // Same reason as login's onPrompt: the approval URL is useless once the wait is over.
317
- // Alongside onHandoff below, never instead of it - see the login call site.
318
- ctx: cliHitlContext(ctx),
319
- onHandoff: ({ url, browserOpened }) => {
320
- notify(ctx, browserOpened
321
- ? glyphs.info(`Opened your browser to approve: ${url}`)
322
- : glyphs.warning(`Could not open a browser - approve here: ${url}`));
323
- },
324
- }, input));
325
- const outcome = classifyWalletSend(result);
326
- ctx.outcome.exit = outcome.exit;
327
- printResult(ctx, result, () => {
328
- const lines = [typeof result.text === 'string' ? result.text : JSON.stringify(result)];
343
+ // LANE ARBITRATION STAYS HERE - it is the decision `submitIntent` deliberately does not make
344
+ // (see its own doc comment in sign.ts). Only the browser lane parks a sign request; the mandate
345
+ // lane signs locally and never reaches submitIntent at all.
346
+ if (lane === 'mandate') {
347
+ const relay = await step(ctx, 'Signing under your mandate', () => signUnderMandateReal(ctx.runtime, mandate.grant, input));
348
+ const result = {
349
+ lane: 'agent',
350
+ text: `Signed locally under this machine's own mandate on account ${session?.accountId ?? ''} - no ` +
351
+ 'browser was opened. The on-chain mandate is what authorized this, not a click.',
352
+ relay,
353
+ };
354
+ const outcome = classifyWalletSend(result);
355
+ ctx.outcome.exit = outcome.exit;
356
+ printResult(ctx, result, () => {
357
+ const lines = [result.text];
358
+ if (outcome.note)
359
+ lines.push(colors.dim(outcome.note));
360
+ return lines.join('\n');
361
+ });
362
+ return;
363
+ }
364
+ // Advisory only - never blocks the handoff. Computed before parking so its warning still reads
365
+ // as "before you approve" rather than a footnote after the fact.
366
+ const preflightWarning = await preflightReal(ctx.runtime, walletToken)(input);
367
+ const outcome = await submitIntent(ctx, 'send', {
368
+ chainKey: input.chainKey,
369
+ token: input.asset.assetRef,
370
+ amount: String(input.amount),
371
+ amountRaw: input.amountRaw,
372
+ to: input.recipient,
373
+ });
374
+ printResult(ctx, outcome.data, () => {
375
+ const lines = [describeSignOutcome(outcome)];
376
+ if (preflightWarning)
377
+ lines.push(colors.dim(`Note: ${preflightWarning}`));
329
378
  if (outcome.note)
330
379
  lines.push(colors.dim(outcome.note));
331
380
  return lines.join('\n');
332
381
  });
333
382
  }), 'Wallet');
383
+ group(program
384
+ .command('swap')
385
+ .description('Swap tokens in an account you own')
386
+ .option('--token-in <addressOrAssetRef>', 'Token to sell - contract address or CAIP-19 asset ref, never a symbol')
387
+ .option('--token-out <addressOrAssetRef>', 'Token to buy - contract address or CAIP-19 asset ref, never a symbol')
388
+ .option('--amount-in <amount>', 'Amount of --token-in to sell, in display units, e.g. 25')
389
+ .option('--chain-id <chainId>', 'Chain to swap on - see `botanary chains`')
390
+ .option('--max-slippage-bps <bps>', 'Maximum acceptable slippage, in basis points')
391
+ .action(async (opts) => {
392
+ const ctx = program.opts().__ctx;
393
+ const walletToken = await requireWalletToken(ctx);
394
+ const tokenInRaw = opts.tokenIn;
395
+ const tokenOutRaw = opts.tokenOut;
396
+ const amountInRaw = opts.amountIn;
397
+ const chainId = opts.chainId ?? ctx.chain;
398
+ if (!chainId || !tokenInRaw || !tokenOutRaw || !amountInRaw) {
399
+ throw invalidArgs('Missing required input', 'Use --token-in, --token-out, --amount-in, and --chain-id.');
400
+ }
401
+ const chains = await step(ctx, 'Reading chains', () => ctx.runtime.api.get('/v1/chains', walletToken));
402
+ const chain = findChain(chains, chainId);
403
+ assertTierAllows(chain, 'send');
404
+ if (chain.chainId === null) {
405
+ throw invalidArgs(`${chain.name} (${chain.key}) has no EVM chain id - swap is EVM-only today.`, 'Pick an EVM chain - see `botanary chains`.');
406
+ }
407
+ const amountIn = parseAmount(amountInRaw, '--amount-in');
408
+ // Fetched unconditionally, same as `send`: a symbol refusal that names real candidates instead
409
+ // of complaining into the void needs this even on the success path, because the failure can be
410
+ // discovered only after trying to validate the flag.
411
+ const balance = await step(ctx, 'Reading balances', () => ctx.runtime.api.get('/v1/balance', walletToken));
412
+ const held = tokenChoicesFor(balance, chain.chainId);
413
+ for (const [raw, flag] of [
414
+ [tokenInRaw, '--token-in'],
415
+ [tokenOutRaw, '--token-out'],
416
+ ]) {
417
+ if (!isTokenRef(raw)) {
418
+ const matches = held.filter((h) => h.symbol.toUpperCase() === String(raw).trim().toUpperCase());
419
+ throw invalidArgs(symbolRefusalMessage(String(raw).trim(), chain.name, matches), `Run \`botanary tokens\` to list every token this account holds, then pass an address to ${flag}.`);
420
+ }
421
+ }
422
+ const [assetIn, assetOut] = await Promise.all([
423
+ step(ctx, 'Resolving token in', () => resolveAsset(ctx.runtime, chain.chainId, tokenInRaw, walletToken)),
424
+ step(ctx, 'Resolving token out', () => resolveAsset(ctx.runtime, chain.chainId, tokenOutRaw, walletToken)),
425
+ ]);
426
+ const rows = [
427
+ { label: 'sell', value: `${amountIn} ${assetIn.symbol}` },
428
+ { label: 'buy', value: assetOut.symbol },
429
+ { label: 'chain', value: `${chain.name} (${chain.key}, ${chain.tier})` },
430
+ { label: 'signed by', value: 'you, in your browser' },
431
+ ];
432
+ const warnings = [];
433
+ if (assetIn.source === 'chain' || assetOut.source === 'chain') {
434
+ warnings.push('At least one token is unrecognised by Botanary - nothing but the contract itself vouches for its name.');
435
+ }
436
+ if (chain.testnet)
437
+ warnings.push('This is a testnet chain.');
438
+ if (ctx.dryRun) {
439
+ const payload = dryRunPayload('Review this swap', rows, warnings);
440
+ printResult(ctx, payload, () => renderReview('Dry run - nothing will be sent', rows, warnings));
441
+ return;
442
+ }
443
+ await confirmOrThrow(ctx, 'Review this swap', rows, warnings);
444
+ const outcome = await submitIntent(ctx, 'swap', {
445
+ chainId: chain.chainId,
446
+ fromToken: { symbol: assetIn.symbol, address: assetIn.address },
447
+ toToken: { symbol: assetOut.symbol, address: assetOut.address },
448
+ amountIn: String(amountIn),
449
+ ...(opts.maxSlippageBps ? { maxSlippageBps: Number(opts.maxSlippageBps) } : {}),
450
+ });
451
+ printResult(ctx, outcome.data, () => describeSignOutcome(outcome));
452
+ }), 'Wallet');
334
453
  group(program
335
454
  .command('activity')
336
455
  .description('Recent sends, swaps and yield transactions')
@@ -352,7 +471,7 @@ export function registerWalletCommands(program) {
352
471
  const result = await step(ctx, 'Reading markets', () => fetchComposite(ctx.runtime.api, token, [{ key: 'tokens', path: '/v1/markets/tokens' }]));
353
472
  printResult(ctx, result.data);
354
473
  }), 'Wallet');
355
- group(program
474
+ const yieldCmd = group(program
356
475
  .command('yield')
357
476
  .description('Yield pools, a shortlist, and this account\'s positions')
358
477
  .action(async () => {
@@ -369,6 +488,259 @@ export function registerWalletCommands(program) {
369
488
  scopeCaveats: 'shortlist is ranked off your own holdings server-side and is not scoped by accounts use.',
370
489
  });
371
490
  }), 'Wallet');
491
+ /** Shared by `yield deposit` and `yield withdraw` - identical shape (`--pool-id`, `--amount`,
492
+ * `--chain-id`), differing only in the sign-request `kind` and the review title. `yield claim` is NOT
493
+ * built off this: `CliFarmClaimIntentDto` carries no `amount` field at all - the backend decides what
494
+ * is claimable from a live probe of the account's own accrued rewards, so there is nothing for an
495
+ * `--amount` flag on claim to mean. */
496
+ function registerFarmAmountCommand(kind, name, verb) {
497
+ yieldCmd
498
+ .command(name)
499
+ .description(`${verb[0].toUpperCase()}${verb.slice(1)} a yield pool, for an account you own`)
500
+ .option('--pool-id <poolId>', 'Pool id - see `botanary yield`')
501
+ .option('--amount <amount>', `Amount of the pool's underlying asset, in display units, e.g. 25`)
502
+ .option('--chain-id <chainId>', 'Chain the pool is on - see `botanary yield`')
503
+ .action(async (opts) => {
504
+ const ctx = program.opts().__ctx;
505
+ const walletToken = await requireWalletToken(ctx);
506
+ const poolId = opts.poolId;
507
+ const amountRaw = opts.amount;
508
+ const chainId = opts.chainId ?? ctx.chain;
509
+ if (!poolId || !amountRaw || !chainId) {
510
+ throw invalidArgs('Missing required input', 'Use --pool-id, --amount, and --chain-id.');
511
+ }
512
+ const chains = await step(ctx, 'Reading chains', () => ctx.runtime.api.get('/v1/chains', walletToken));
513
+ const chain = findChain(chains, chainId);
514
+ assertTierAllows(chain, 'send');
515
+ if (chain.chainId === null) {
516
+ throw invalidArgs(`${chain.name} (${chain.key}) has no EVM chain id - ${verb} is EVM-only today.`, 'Pick an EVM chain - see `botanary chains`.');
517
+ }
518
+ const amount = parseAmount(amountRaw);
519
+ const rows = [
520
+ { label: 'pool', value: String(poolId) },
521
+ { label: 'amount', value: String(amount) },
522
+ { label: 'chain', value: `${chain.name} (${chain.key}, ${chain.tier})` },
523
+ { label: 'signed by', value: 'you, in your browser' },
524
+ ];
525
+ const warnings = [];
526
+ if (chain.testnet)
527
+ warnings.push('This is a testnet chain.');
528
+ if (ctx.dryRun) {
529
+ const payload = dryRunPayload(`Review this ${verb}`, rows, warnings);
530
+ printResult(ctx, payload, () => renderReview('Dry run - nothing will be sent', rows, warnings));
531
+ return;
532
+ }
533
+ await confirmOrThrow(ctx, `Review this ${verb}`, rows, warnings);
534
+ const outcome = await submitIntent(ctx, kind, {
535
+ poolId: String(poolId),
536
+ chainId: chain.chainId,
537
+ amount: String(amount),
538
+ });
539
+ printResult(ctx, outcome.data, () => describeSignOutcome(outcome));
540
+ });
541
+ }
542
+ registerFarmAmountCommand('farm.deposit', 'deposit', 'deposit');
543
+ registerFarmAmountCommand('farm.withdraw', 'withdraw', 'withdraw');
544
+ yieldCmd
545
+ .command('claim')
546
+ .description('Claim accrued rewards from a yield pool, for an account you own')
547
+ .option('--pool-id <poolId>', 'Pool id - see `botanary yield`')
548
+ .option('--chain-id <chainId>', 'Chain the pool is on - see `botanary yield`')
549
+ .action(async (opts) => {
550
+ const ctx = program.opts().__ctx;
551
+ const walletToken = await requireWalletToken(ctx);
552
+ const poolId = opts.poolId;
553
+ const chainId = opts.chainId ?? ctx.chain;
554
+ if (!poolId || !chainId) {
555
+ throw invalidArgs('Missing required input', 'Use --pool-id and --chain-id.');
556
+ }
557
+ const chains = await step(ctx, 'Reading chains', () => ctx.runtime.api.get('/v1/chains', walletToken));
558
+ const chain = findChain(chains, chainId);
559
+ assertTierAllows(chain, 'send');
560
+ if (chain.chainId === null) {
561
+ throw invalidArgs(`${chain.name} (${chain.key}) has no EVM chain id - claim is EVM-only today.`, 'Pick an EVM chain - see `botanary chains`.');
562
+ }
563
+ const rows = [
564
+ { label: 'pool', value: String(poolId) },
565
+ { label: 'chain', value: `${chain.name} (${chain.key}, ${chain.tier})` },
566
+ { label: 'signed by', value: 'you, in your browser' },
567
+ ];
568
+ const warnings = [];
569
+ if (chain.testnet)
570
+ warnings.push('This is a testnet chain.');
571
+ if (ctx.dryRun) {
572
+ const payload = dryRunPayload('Review this claim', rows, warnings);
573
+ printResult(ctx, payload, () => renderReview('Dry run - nothing will be sent', rows, warnings));
574
+ return;
575
+ }
576
+ await confirmOrThrow(ctx, 'Review this claim', rows, warnings);
577
+ const outcome = await submitIntent(ctx, 'farm.claim', {
578
+ poolId: String(poolId),
579
+ chainId: chain.chainId,
580
+ });
581
+ printResult(ctx, outcome.data, () => describeSignOutcome(outcome));
582
+ });
583
+ // ---- solana: send, swap, and Botanary Mandates Program writes -------------------------------------
584
+ // Solana is key-keyed, not chainId-keyed (workspace CLAUDE.md, "Chain families and tiers") - every
585
+ // command below takes --key (see `botanary chains`, e.g. "solana" or "solana-devnet") rather than
586
+ // --chain-id. Kept as its own group rather than folded into `send`/`swap` above: the DTOs genuinely
587
+ // differ (raw base-unit amount strings, a `key` selector, no `resolveAsset`/mandate-lane round trip),
588
+ // so branching one command on chain family would replace one simple code path with two.
589
+ const solana = group(program.command('solana').description('Send, swap and manage Botanary Mandates on Solana'), 'Wallet');
590
+ solana
591
+ .command('send')
592
+ .description('Send tokens on Solana')
593
+ .requiredOption('--key <key>', 'Solana chain key - see `botanary chains`, e.g. solana or solana-devnet')
594
+ .requiredOption('--to <address>', 'Destination Solana address')
595
+ .option('--mint <address>', 'SPL token mint - omit to send native SOL')
596
+ .requiredOption('--amount <amount>', 'Raw base-unit amount (lamports for SOL, raw token units for SPL) - not display units')
597
+ .option('--gas-method <method>', 'native, or usdc routed through Kora, gasless')
598
+ .option('--account-id <accountId>', 'Specific account of yours - omit for your default')
599
+ .action(async (opts) => {
600
+ const ctx = program.opts().__ctx;
601
+ await requireWalletToken(ctx);
602
+ if (opts.gasMethod !== undefined && opts.gasMethod !== 'native' && opts.gasMethod !== 'usdc') {
603
+ throw invalidArgs(`--gas-method must be "native" or "usdc", got ${JSON.stringify(opts.gasMethod)}.`);
604
+ }
605
+ const to = assertAddress(opts.to, 'solana');
606
+ const rows = [
607
+ { label: 'amount', value: `${opts.amount} (raw base units)` },
608
+ { label: 'to', value: to },
609
+ { label: 'chain', value: opts.key },
610
+ { label: 'signed by', value: 'you, in your browser' },
611
+ ];
612
+ await reviewAndSubmit(ctx, 'Review this Solana send', rows, [], 'solana.send', {
613
+ key: opts.key,
614
+ to,
615
+ amount: opts.amount,
616
+ ...(opts.mint ? { mint: opts.mint } : {}),
617
+ ...(opts.gasMethod ? { gasMethod: opts.gasMethod } : {}),
618
+ ...(opts.accountId ? { accountId: opts.accountId } : {}),
619
+ });
620
+ });
621
+ solana
622
+ .command('swap')
623
+ .description('Swap tokens on Solana')
624
+ .requiredOption('--key <key>', 'Solana chain key - see `botanary chains`')
625
+ .option('--from-mint <address>', 'Mint to sell - omit for native SOL')
626
+ .option('--to-mint <address>', 'Mint to buy - omit for native SOL')
627
+ .requiredOption('--amount-in <amount>', 'Amount of the sell side, in HUMAN-SCALE units (e.g. 1.5), not raw base units')
628
+ .requiredOption('--max-slippage-bps <bps>', 'Maximum acceptable slippage, in basis points')
629
+ .option('--gas-method <method>', 'native, or usdc routed through Kora, gasless')
630
+ .option('--account-id <accountId>', 'Specific account of yours - omit for your default')
631
+ .action(async (opts) => {
632
+ const ctx = program.opts().__ctx;
633
+ await requireWalletToken(ctx);
634
+ if (opts.gasMethod !== undefined && opts.gasMethod !== 'native' && opts.gasMethod !== 'usdc') {
635
+ throw invalidArgs(`--gas-method must be "native" or "usdc", got ${JSON.stringify(opts.gasMethod)}.`);
636
+ }
637
+ const amountIn = parseAmount(opts.amountIn, '--amount-in');
638
+ const maxSlippageBps = Number(opts.maxSlippageBps);
639
+ if (!Number.isInteger(maxSlippageBps) || maxSlippageBps < 0) {
640
+ throw invalidArgs(`--max-slippage-bps must be a non-negative integer, got ${JSON.stringify(opts.maxSlippageBps)}.`);
641
+ }
642
+ const rows = [
643
+ { label: 'sell', value: `${amountIn}${opts.fromMint ? ` of ${opts.fromMint}` : ' SOL'}` },
644
+ { label: 'buy', value: opts.toMint ?? 'SOL' },
645
+ { label: 'chain', value: opts.key },
646
+ { label: 'signed by', value: 'you, in your browser' },
647
+ ];
648
+ await reviewAndSubmit(ctx, 'Review this Solana swap', rows, [], 'solana.swap', {
649
+ key: opts.key,
650
+ amountIn,
651
+ maxSlippageBps,
652
+ ...(opts.fromMint ? { fromMint: opts.fromMint } : {}),
653
+ ...(opts.toMint ? { toMint: opts.toMint } : {}),
654
+ ...(opts.gasMethod ? { gasMethod: opts.gasMethod } : {}),
655
+ ...(opts.accountId ? { accountId: opts.accountId } : {}),
656
+ });
657
+ });
658
+ const solanaMandate = solana
659
+ .command('mandate')
660
+ .description('Grant, freeze/unfreeze and revoke Botanary Mandates Program delegations on Solana');
661
+ solanaMandate
662
+ .command('grant')
663
+ .description('Grant a bounded Solana mandate')
664
+ .requiredOption('--key <key>', 'Solana chain key - see `botanary chains`')
665
+ .requiredOption('--mint <address>', 'The SPL token mint this mandate bounds')
666
+ .requiredOption('--recipient <address...>', 'An allowlisted recipient - repeat for each (at least one, fail-closed)')
667
+ .option('--venue <address...>', 'An allowlisted venue program id - repeat for each; omit for none')
668
+ .requiredOption('--per-action-max <amount>', 'Raw base-unit max spend per action')
669
+ .requiredOption('--cap-limit <amount>', 'Raw base-unit budget per --cap-period-seconds window')
670
+ .requiredOption('--cap-period-seconds <seconds>', 'The window --cap-limit applies over, at least 3600 (1 hour)')
671
+ .requiredOption('--expiry <iso>', 'When this mandate expires, ISO 8601 - mandatory, unlike the EVM delegation lane')
672
+ .option('--account-id <accountId>', 'Specific account of yours - omit for your default')
673
+ .action(async (opts) => {
674
+ const ctx = program.opts().__ctx;
675
+ await requireWalletToken(ctx);
676
+ const capPeriodSeconds = Number(opts.capPeriodSeconds);
677
+ if (!Number.isInteger(capPeriodSeconds) || capPeriodSeconds < 3600) {
678
+ throw invalidArgs(`--cap-period-seconds must be an integer of at least 3600, got ${JSON.stringify(opts.capPeriodSeconds)}.`);
679
+ }
680
+ const rows = [
681
+ { label: 'mint', value: opts.mint },
682
+ { label: 'recipients', value: opts.recipient.join(', ') },
683
+ { label: 'venues', value: opts.venue?.length ? opts.venue.join(', ') : 'none' },
684
+ { label: 'per-action max', value: `${opts.perActionMax} (raw base units)` },
685
+ { label: 'cap', value: `${opts.capLimit} (raw base units) every ${capPeriodSeconds}s` },
686
+ { label: 'expiry', value: opts.expiry },
687
+ { label: 'chain', value: opts.key },
688
+ { label: 'signed by', value: 'you, in your browser' },
689
+ ];
690
+ await reviewAndSubmit(ctx, 'Review this Solana mandate grant', rows, [], 'solana.mandate.grant', {
691
+ key: opts.key,
692
+ mint: opts.mint,
693
+ recipientAllowlist: opts.recipient,
694
+ venueAllowlist: opts.venue ?? [],
695
+ perActionMax: opts.perActionMax,
696
+ capLimit: opts.capLimit,
697
+ capPeriodSeconds,
698
+ expiry: opts.expiry,
699
+ ...(opts.accountId ? { accountId: opts.accountId } : {}),
700
+ });
701
+ });
702
+ solanaMandate
703
+ .command('freeze')
704
+ .description('Freeze (or unfreeze) every Solana mandate for this owner - one kill switch across every mint')
705
+ .requiredOption('--key <key>', 'Solana chain key - see `botanary chains`')
706
+ .option('--unfreeze', 'Unfreeze instead of freeze')
707
+ .option('--account-id <accountId>', 'Specific account of yours - omit for your default')
708
+ .action(async (opts) => {
709
+ const ctx = program.opts().__ctx;
710
+ await requireWalletToken(ctx);
711
+ const frozen = !opts.unfreeze;
712
+ const rows = [
713
+ {
714
+ label: 'action',
715
+ value: frozen ? 'freeze every Solana mandate for this owner' : 'unfreeze every Solana mandate for this owner',
716
+ },
717
+ { label: 'chain', value: opts.key },
718
+ { label: 'signed by', value: 'you, in your browser' },
719
+ ];
720
+ const warnings = frozen
721
+ ? ['This stops the copilot acting under ANY Solana mandate for this owner, across every mint, instantly.']
722
+ : [];
723
+ await reviewAndSubmit(ctx, `Review this Solana mandate ${frozen ? 'freeze' : 'unfreeze'}`, rows, warnings, 'solana.mandate.freeze', { key: opts.key, frozen, ...(opts.accountId ? { accountId: opts.accountId } : {}) });
724
+ });
725
+ solanaMandate
726
+ .command('revoke <id>')
727
+ .description('Revoke a Solana mandate')
728
+ .requiredOption('--key <key>', 'Solana chain key - see `botanary chains`')
729
+ .option('--account-id <accountId>', 'Specific account of yours - omit for your default')
730
+ .action(async (id, opts) => {
731
+ const ctx = program.opts().__ctx;
732
+ await requireWalletToken(ctx);
733
+ const rows = [
734
+ { label: 'mandate', value: id },
735
+ { label: 'chain', value: opts.key },
736
+ { label: 'signed by', value: 'you, in your browser' },
737
+ ];
738
+ await reviewAndSubmit(ctx, 'Review this Solana mandate revoke', rows, [], 'solana.mandate.revoke', {
739
+ id,
740
+ key: opts.key,
741
+ ...(opts.accountId ? { accountId: opts.accountId } : {}),
742
+ });
743
+ });
372
744
  const mandates = group(program
373
745
  .command('mandates')
374
746
  .description('What the agent lane may spend unattended (testnet only)')
@@ -421,6 +793,152 @@ export function registerWalletCommands(program) {
421
793
  ].join('\n')
422
794
  : `${section('Suggested mandates')}\n${colors.muted(` ${payload.note}`)}`);
423
795
  });
796
+ // ---- mandates writes: two parallel systems, two distinct id spaces --------------------------------
797
+ // `botanary mandates` above already reads BOTH tables side by side (see `render/mandates.ts`):
798
+ // "Delegations (Smart Sessions)" - the classic EVM Smart Session grant lane, `delegation.*` kinds,
799
+ // ids from `POST /delegations` - and "Mandates (on-chain)" - the newer rules/mandates system,
800
+ // `mandate.*` kinds, ids from `POST /mandates/confirm`. The write verbs below are deliberately named
801
+ // to keep the two apart: `grant`/`freeze`/`unfreeze`/`revoke` target a DELEGATION id (the first
802
+ // table); `confirm`/`pause`/`resume`/`cancel` target a MANDATE id (the second table). `cancel`, not
803
+ // `revoke`, for `mandate.revoke` - `revoke` was already spoken for by `delegation.revoke` above it,
804
+ // and reusing one verb across two id spaces is exactly the kind of ambiguity a CLI cannot recover
805
+ // from the way a web form with two separate buttons can.
806
+ mandates
807
+ .command('grant')
808
+ .description('Grant bounded access to a delegatee agent (a Smart Session - the classic delegation lane)')
809
+ .requiredOption('--grant-file <path>', 'JSON object: { name, delegateeAddress, chainId, budgets: [{ token: { symbol }, amount }], ... } - ' +
810
+ 'see the README for the full shape (perActionMax, recipientAllowlist, allowedContract, deniedContracts, ' +
811
+ 'conditions, maxActions, rate, expiresAt, alsoOnChainIds, agentId, mandateId, mandateVersion)')
812
+ .option('--name <name>', "Override the file's name field")
813
+ .option('--delegatee-address <address>', "Override the file's delegateeAddress field")
814
+ .option('--chain-id <chainId>', "Override the file's chainId field")
815
+ .action(async (opts) => {
816
+ const ctx = program.opts().__ctx;
817
+ await requireWalletToken(ctx);
818
+ const fileParams = await readJsonFile(opts.grantFile, '--grant-file');
819
+ if (typeof fileParams !== 'object' || fileParams === null || Array.isArray(fileParams)) {
820
+ throw invalidArgs('--grant-file must contain a single JSON object.');
821
+ }
822
+ const params = { ...fileParams };
823
+ if (opts.name !== undefined)
824
+ params.name = opts.name;
825
+ if (opts.delegateeAddress !== undefined)
826
+ params.delegateeAddress = opts.delegateeAddress;
827
+ if (opts.chainId !== undefined)
828
+ params.chainId = Number(opts.chainId);
829
+ if (typeof params.name !== 'string' || !params.name) {
830
+ throw invalidArgs('name is required.', 'Set it in --grant-file, or pass --name.');
831
+ }
832
+ if (typeof params.delegateeAddress !== 'string' || !params.delegateeAddress) {
833
+ throw invalidArgs('delegateeAddress is required.', 'Set it in --grant-file, or pass --delegatee-address.');
834
+ }
835
+ if (typeof params.chainId !== 'number' || !Number.isFinite(params.chainId)) {
836
+ throw invalidArgs('chainId is required.', 'Set it in --grant-file, or pass --chain-id.');
837
+ }
838
+ if (!Array.isArray(params.budgets) || params.budgets.length === 0) {
839
+ throw invalidArgs('budgets is required and must be a non-empty array.', 'Set it in --grant-file - see the README for the shape.');
840
+ }
841
+ const rows = [
842
+ { label: 'name', value: String(params.name) },
843
+ { label: 'delegatee', value: String(params.delegateeAddress) },
844
+ { label: 'chain id', value: String(params.chainId) },
845
+ { label: 'budgets', value: String(params.budgets.length) },
846
+ { label: 'signed by', value: 'you, in your browser' },
847
+ ];
848
+ await reviewAndSubmit(ctx, 'Review this mandate grant', rows, [], 'delegation.grant', params);
849
+ });
850
+ mandates
851
+ .command('freeze <delegationId>')
852
+ .description('Freeze a delegation (reversible) - id from the Delegations table in `botanary mandates`')
853
+ .action(async (delegationId) => {
854
+ const ctx = program.opts().__ctx;
855
+ await requireWalletToken(ctx);
856
+ const rows = [
857
+ { label: 'delegation', value: delegationId },
858
+ { label: 'signed by', value: 'you, in your browser' },
859
+ ];
860
+ await reviewAndSubmit(ctx, 'Review this delegation freeze', rows, [], 'delegation.freeze', { delegationId });
861
+ });
862
+ mandates
863
+ .command('unfreeze <delegationId>')
864
+ .description('Unfreeze a delegation - id from the Delegations table in `botanary mandates`')
865
+ .action(async (delegationId) => {
866
+ const ctx = program.opts().__ctx;
867
+ await requireWalletToken(ctx);
868
+ const rows = [
869
+ { label: 'delegation', value: delegationId },
870
+ { label: 'signed by', value: 'you, in your browser' },
871
+ ];
872
+ await reviewAndSubmit(ctx, 'Review this delegation unfreeze', rows, [], 'delegation.unfreeze', { delegationId });
873
+ });
874
+ mandates
875
+ .command('revoke <delegationId>')
876
+ .description('Revoke a delegation (permanent) - id from the Delegations table in `botanary mandates`')
877
+ .action(async (delegationId) => {
878
+ const ctx = program.opts().__ctx;
879
+ await requireWalletToken(ctx);
880
+ const rows = [
881
+ { label: 'delegation', value: delegationId },
882
+ { label: 'signed by', value: 'you, in your browser' },
883
+ ];
884
+ const warnings = ['This is permanent - use `botanary mandates freeze` instead if you may want it back.'];
885
+ await reviewAndSubmit(ctx, 'Review this delegation revoke', rows, warnings, 'delegation.revoke', { delegationId });
886
+ });
887
+ mandates
888
+ .command('confirm')
889
+ .description('Confirm and enable proposed mandates or rules - candidates usually come from `botanary mandates propose`')
890
+ .requiredOption('--candidates-file <path>', 'JSON array of proposed objects, typically saved from `botanary mandates propose --json`')
891
+ .action(async (opts) => {
892
+ const ctx = program.opts().__ctx;
893
+ await requireWalletToken(ctx);
894
+ const candidates = await readJsonFile(opts.candidatesFile, '--candidates-file');
895
+ if (!Array.isArray(candidates) || candidates.length === 0) {
896
+ throw invalidArgs('--candidates-file must contain a non-empty JSON array.');
897
+ }
898
+ const rows = [
899
+ { label: 'candidates', value: String(candidates.length) },
900
+ { label: 'signed by', value: 'you, in your browser' },
901
+ ];
902
+ await reviewAndSubmit(ctx, 'Review this mandate confirm', rows, [], 'mandate.confirm', { candidates });
903
+ });
904
+ mandates
905
+ .command('pause <mandateId>')
906
+ .description('Pause a mandate (reversible) - id from the Mandates (on-chain) table in `botanary mandates`')
907
+ .action(async (mandateId) => {
908
+ const ctx = program.opts().__ctx;
909
+ await requireWalletToken(ctx);
910
+ const rows = [
911
+ { label: 'mandate', value: mandateId },
912
+ { label: 'signed by', value: 'you, in your browser' },
913
+ ];
914
+ await reviewAndSubmit(ctx, 'Review this mandate pause', rows, [], 'mandate.pause', { mandateId });
915
+ });
916
+ mandates
917
+ .command('resume <mandateId>')
918
+ .description('Resume a paused mandate - id from the Mandates (on-chain) table in `botanary mandates`')
919
+ .action(async (mandateId) => {
920
+ const ctx = program.opts().__ctx;
921
+ await requireWalletToken(ctx);
922
+ const rows = [
923
+ { label: 'mandate', value: mandateId },
924
+ { label: 'signed by', value: 'you, in your browser' },
925
+ ];
926
+ await reviewAndSubmit(ctx, 'Review this mandate resume', rows, [], 'mandate.resume', { mandateId });
927
+ });
928
+ mandates
929
+ .command('cancel <mandateId>')
930
+ .description('Revoke a mandate (permanent) - named cancel, not revoke, to stay distinct from `mandates revoke`, which ' +
931
+ 'targets a delegation, not a mandate. Id from the Mandates (on-chain) table in `botanary mandates`.')
932
+ .action(async (mandateId) => {
933
+ const ctx = program.opts().__ctx;
934
+ await requireWalletToken(ctx);
935
+ const rows = [
936
+ { label: 'mandate', value: mandateId },
937
+ { label: 'signed by', value: 'you, in your browser' },
938
+ ];
939
+ const warnings = ['This is permanent - use `botanary mandates pause` instead if you may want it back.'];
940
+ await reviewAndSubmit(ctx, 'Review this mandate cancel', rows, warnings, 'mandate.revoke', { mandateId });
941
+ });
424
942
  group(program
425
943
  .command('agents')
426
944
  .description('Connected agents and any pending approval requests')