botanary 0.2.2 → 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.
Files changed (37) hide show
  1. package/README.md +140 -2
  2. package/dist/bin/botanary.js +5 -2
  3. package/dist/bin/botanary.js.map +1 -1
  4. package/dist/package.json +15 -8
  5. package/dist/src/app-url.js +19 -2
  6. package/dist/src/app-url.js.map +1 -1
  7. package/dist/src/cli.js +17 -0
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/commands/account.js +171 -0
  10. package/dist/src/commands/account.js.map +1 -0
  11. package/dist/src/commands/agent.js +6 -6
  12. package/dist/src/commands/agent.js.map +1 -1
  13. package/dist/src/commands/authority.js +634 -0
  14. package/dist/src/commands/authority.js.map +1 -0
  15. package/dist/src/commands/services.js +434 -0
  16. package/dist/src/commands/services.js.map +1 -0
  17. package/dist/src/commands/session-writes.js +196 -0
  18. package/dist/src/commands/session-writes.js.map +1 -0
  19. package/dist/src/commands/sign.js +306 -0
  20. package/dist/src/commands/sign.js.map +1 -0
  21. package/dist/src/commands/tokens.js +36 -0
  22. package/dist/src/commands/tokens.js.map +1 -0
  23. package/dist/src/commands/wallet.js +596 -44
  24. package/dist/src/commands/wallet.js.map +1 -1
  25. package/dist/src/commands/watch.js +259 -0
  26. package/dist/src/commands/watch.js.map +1 -0
  27. package/dist/src/help/examples.js +538 -8
  28. package/dist/src/help/examples.js.map +1 -1
  29. package/dist/src/package-version.js +53 -0
  30. package/dist/src/package-version.js.map +1 -0
  31. package/dist/src/progress.js +19 -0
  32. package/dist/src/progress.js.map +1 -1
  33. package/dist/src/render/balance.js +19 -2
  34. package/dist/src/render/balance.js.map +1 -1
  35. package/dist/src/validate.js +55 -9
  36. package/dist/src/validate.js.map +1 -1
  37. package/package.json +24 -18
@@ -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, 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';
@@ -13,15 +15,20 @@ import { renderChains } from '../render/chains.js';
13
15
  import { renderGas } from '../render/gas.js';
14
16
  import { renderAccounts } from '../render/accounts.js';
15
17
  import { section } from '../render/kv.js';
16
- import { step, notify } from '../progress.js';
18
+ import { step, notify, cliHitlContext } from '../progress.js';
17
19
  import { confirmOrThrow, dryRunPayload, renderReview } from '../review.js';
18
20
  import { classifyWalletSend } from '../outcome.js';
19
- import { assertAddress, assertTierAllows, familyOf, findChain, parseAmount, tokenChoicesFor } from '../validate.js';
21
+ import { assertAddress, assertTierAllows, familyOf, findChain, isTokenRef, parseAmount, symbolRefusalMessage, tokenChoicesFor, } from '../validate.js';
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')
@@ -44,6 +80,9 @@ export function registerWalletCommands(program) {
44
80
  api: ctx.runtime.api,
45
81
  store: ctx.runtime.walletSessionStore,
46
82
  openBrowser,
83
+ // Alongside onPrompt below, never instead of it: onPrompt prints the code and the URL once,
84
+ // and this reports each status change while the wait runs. Silent under --json.
85
+ ctx: cliHitlContext(ctx),
47
86
  now: () => Date.now(),
48
87
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
49
88
  // The whole point of the 0.6.0 callback: say the code and the URL NOW, not when the flow ends
@@ -58,7 +97,10 @@ export function registerWalletCommands(program) {
58
97
  });
59
98
  if (result.status === 'authorized') {
60
99
  if (ctx.json) {
61
- 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 });
62
104
  }
63
105
  else {
64
106
  console.log(glyphs.success(`Logged in${result.session ? ` (account ${result.session.accountId ?? 'default'})` : ''}`));
@@ -171,7 +213,7 @@ export function registerWalletCommands(program) {
171
213
  .description('Send tokens from an account you own')
172
214
  .option('--recipient <address>', 'Destination address (0x... on EVM)')
173
215
  .option('--amount <amount>', 'Amount in display units, e.g. 25')
174
- .option('--token <symbol>', 'Token symbol, e.g. USDC')
216
+ .option('--token <addressOrAssetRef>', 'Token contract address (0x...) or CAIP-19 asset ref - see `botanary tokens`')
175
217
  .option('--chain-id <chainId>', 'Chain to send on - see `botanary chains`')
176
218
  .action(async (opts) => {
177
219
  const ctx = program.opts().__ctx;
@@ -179,19 +221,23 @@ export function registerWalletCommands(program) {
179
221
  // Validate required inputs before making network calls. If chain-id is not specified and we're
180
222
  // not in interactive mode, fail immediately with missing input - do not fetch chains first.
181
223
  let amountRaw = opts.amount;
182
- let tokenSymbol = opts.token;
224
+ let tokenRaw = opts.token;
183
225
  let recipient = opts.recipient;
184
226
  const chainId = opts.chainId ?? ctx.chain;
185
- if (!ctx.interactive && (!chainId || !amountRaw || !tokenSymbol || !recipient)) {
227
+ if (!ctx.interactive && (!chainId || !amountRaw || !tokenRaw || !recipient)) {
186
228
  throw invalidArgs('Missing required input', 'Use --amount, --token, --recipient, and --chain-id, or run in an interactive terminal.');
187
229
  }
188
230
  const chains = await step(ctx, 'Reading chains', () => ctx.runtime.api.get('/v1/chains', walletToken));
189
231
  const chain = findChain(chains, chainId);
190
232
  assertTierAllows(chain, 'send');
191
233
  const family = familyOf(chain.namespace);
192
- if (ctx.interactive && (!amountRaw || !tokenSymbol || !recipient)) {
193
- const balance = await step(ctx, 'Reading balances', () => ctx.runtime.api.get('/v1/balance', walletToken));
194
- const choices = tokenChoicesFor(balance, chain.chainRef);
234
+ // Hoisted out of the interactive-only branch below: the non-interactive path needs `held` too, to
235
+ // build a symbol refusal that names real candidates instead of complaining into the void. A
236
+ // refusal that lists nothing because the CLI declined to look is the papercut this task exists to
237
+ // remove.
238
+ const balance = await step(ctx, 'Reading balances', () => ctx.runtime.api.get('/v1/balance', walletToken));
239
+ const held = tokenChoicesFor(balance, chain.chainId ?? Number.NaN);
240
+ if (ctx.interactive && (!amountRaw || !tokenRaw || !recipient)) {
195
241
  const answers = await clack.group({
196
242
  amount: () => clack.text({
197
243
  message: 'Amount?',
@@ -204,12 +250,18 @@ export function registerWalletCommands(program) {
204
250
  return Number.isFinite(parsed) && parsed > 0 ? undefined : 'A positive number, e.g. 25';
205
251
  },
206
252
  }),
207
- token: () => choices.length
253
+ token: () => held.length
208
254
  ? clack.select({
209
255
  message: `Token? (held on ${chain.name})`,
210
- options: choices.map((choice) => ({ value: choice.symbol, label: `${choice.symbol} (${choice.amount})` })),
256
+ // The picker's VALUE is the assetRef, not the symbol - the same identity `resolveAsset`
257
+ // takes below, so a picked token never re-enters the ambiguity this task removes.
258
+ options: held.map((h) => ({
259
+ value: h.assetRef,
260
+ label: `${h.symbol} (${h.amount ?? 0})`,
261
+ hint: `${h.address} ${h.decimals}dp ${h.source ?? ''}`.trim(),
262
+ })),
211
263
  })
212
- : clack.text({ message: 'Token?', placeholder: 'USDC', initialValue: tokenSymbol }),
264
+ : clack.text({ message: 'Token contract address?', placeholder: '0xAb12...', initialValue: tokenRaw }),
213
265
  recipient: () => clack.text({
214
266
  message: 'To?',
215
267
  placeholder: family === 'evm' ? '0xAb12...9F3' : 'address',
@@ -222,17 +274,28 @@ export function registerWalletCommands(program) {
222
274
  },
223
275
  });
224
276
  amountRaw = answers.amount;
225
- tokenSymbol = answers.token;
277
+ tokenRaw = answers.token;
226
278
  recipient = answers.recipient;
227
279
  }
228
280
  const amount = parseAmount(amountRaw);
229
- if (!tokenSymbol)
230
- throw invalidArgs('--token is required.', 'A token symbol, e.g. USDC. See `botanary balance`.');
281
+ if (!tokenRaw) {
282
+ throw invalidArgs('--token is required.', 'A contract address or CAIP-19 asset ref - see `botanary tokens`.');
283
+ }
284
+ if (!isTokenRef(tokenRaw)) {
285
+ const matches = held.filter((h) => h.symbol.toUpperCase() === String(tokenRaw).trim().toUpperCase());
286
+ throw invalidArgs(symbolRefusalMessage(String(tokenRaw).trim(), chain.name, matches), 'Run `botanary tokens` to list every token this account holds.');
287
+ }
231
288
  const to = assertAddress(recipient, family);
289
+ const asset = await step(ctx, 'Resolving token', () => resolveAsset(ctx.runtime, chain.chainId ?? Number.NaN, tokenRaw, walletToken));
290
+ // From the ORIGINAL typed string, not the round-tripped `amount` number: a float round-trip
291
+ // (`String(0.1 + 0.2)`) can lose or invent trailing digits on a token amount, and base units are
292
+ // exactly the value that must never drift from what the user actually typed.
293
+ const sendAmountRaw = baseUnits(String(amountRaw), asset.decimals);
232
294
  const input = {
233
295
  recipient: to,
234
296
  amount,
235
- tokenSymbol,
297
+ asset,
298
+ amountRaw: sendAmountRaw,
236
299
  chainId: chain.chainId ?? Number.NaN,
237
300
  chainKey: chain.key,
238
301
  };
@@ -242,7 +305,9 @@ export function registerWalletCommands(program) {
242
305
  ? 'mandate'
243
306
  : 'browser';
244
307
  const rows = [
245
- { label: 'amount', value: `${amount} ${tokenSymbol}` },
308
+ { label: 'amount', value: `${amount} ${asset.symbol}` },
309
+ { label: 'token', value: `${asset.symbol} ${asset.address} ${asset.decimals}dp` },
310
+ { label: 'source', value: asset.source },
246
311
  { label: 'to', value: to },
247
312
  { label: 'chain', value: `${chain.name} (${chain.key}, ${chain.tier})` },
248
313
  {
@@ -253,6 +318,12 @@ export function registerWalletCommands(program) {
253
318
  },
254
319
  ];
255
320
  const warnings = [];
321
+ // `source: 'chain'` means resolution fell all the way through to reading the contract itself -
322
+ // nothing in Botanary's registry, discovered set or imported list recognises this token, so the
323
+ // name shown is exactly what the contract claims for itself and nothing more.
324
+ if (asset.source === 'chain') {
325
+ warnings.push(`Botanary does not recognise this token - nothing but the contract itself vouches for the name "${asset.symbol}". Check the address.`);
326
+ }
256
327
  // The backend's own reason, not re-authored here: it is the only sentence that can honestly say
257
328
  // WHY the mandate lane was not taken.
258
329
  if (lane === 'browser' && mandate.accountId !== null && mandate.accountId !== (session?.accountId ?? '')) {
@@ -269,34 +340,116 @@ export function registerWalletCommands(program) {
269
340
  return;
270
341
  }
271
342
  await confirmOrThrow(ctx, 'Review this payment', rows, warnings);
272
- const result = await step(ctx, lane === 'mandate' ? 'Signing under your mandate' : 'Waiting for your approval', () => walletSend({
273
- mandateAccountId: mandate.accountId,
274
- mandateInBounds: mandate.inBounds,
275
- mandateBoundsReason: mandate.reason,
276
- walletAccountId: session?.accountId ?? '',
277
- signUnderMandate: (i) => signUnderMandateReal(ctx.runtime, mandate.grant, i),
278
- createSignRequest: createSignRequestReal(ctx.runtime, walletToken),
279
- getSignRequest: getSignRequestReal(ctx.runtime, walletToken),
280
- openBrowser,
281
- now: () => Date.now(),
282
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
283
- preflight: preflightReal(ctx.runtime, walletToken),
284
- // Same reason as login's onPrompt: the approval URL is useless once the wait is over.
285
- onHandoff: ({ url, browserOpened }) => {
286
- notify(ctx, browserOpened
287
- ? glyphs.info(`Opened your browser to approve: ${url}`)
288
- : glyphs.warning(`Could not open a browser - approve here: ${url}`));
289
- },
290
- }, input));
291
- const outcome = classifyWalletSend(result);
292
- ctx.outcome.exit = outcome.exit;
293
- printResult(ctx, result, () => {
294
- 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}`));
295
378
  if (outcome.note)
296
379
  lines.push(colors.dim(outcome.note));
297
380
  return lines.join('\n');
298
381
  });
299
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');
300
453
  group(program
301
454
  .command('activity')
302
455
  .description('Recent sends, swaps and yield transactions')
@@ -318,7 +471,7 @@ export function registerWalletCommands(program) {
318
471
  const result = await step(ctx, 'Reading markets', () => fetchComposite(ctx.runtime.api, token, [{ key: 'tokens', path: '/v1/markets/tokens' }]));
319
472
  printResult(ctx, result.data);
320
473
  }), 'Wallet');
321
- group(program
474
+ const yieldCmd = group(program
322
475
  .command('yield')
323
476
  .description('Yield pools, a shortlist, and this account\'s positions')
324
477
  .action(async () => {
@@ -335,6 +488,259 @@ export function registerWalletCommands(program) {
335
488
  scopeCaveats: 'shortlist is ranked off your own holdings server-side and is not scoped by accounts use.',
336
489
  });
337
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
+ });
338
744
  const mandates = group(program
339
745
  .command('mandates')
340
746
  .description('What the agent lane may spend unattended (testnet only)')
@@ -387,6 +793,152 @@ export function registerWalletCommands(program) {
387
793
  ].join('\n')
388
794
  : `${section('Suggested mandates')}\n${colors.muted(` ${payload.note}`)}`);
389
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
+ });
390
942
  group(program
391
943
  .command('agents')
392
944
  .description('Connected agents and any pending approval requests')