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.
- package/README.md +140 -2
- package/dist/bin/botanary.js +5 -2
- package/dist/bin/botanary.js.map +1 -1
- package/dist/package.json +15 -8
- package/dist/src/app-url.js +19 -2
- package/dist/src/app-url.js.map +1 -1
- package/dist/src/cli.js +17 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/account.js +171 -0
- package/dist/src/commands/account.js.map +1 -0
- package/dist/src/commands/agent.js +6 -6
- package/dist/src/commands/agent.js.map +1 -1
- package/dist/src/commands/authority.js +634 -0
- package/dist/src/commands/authority.js.map +1 -0
- package/dist/src/commands/services.js +434 -0
- package/dist/src/commands/services.js.map +1 -0
- package/dist/src/commands/session-writes.js +196 -0
- package/dist/src/commands/session-writes.js.map +1 -0
- package/dist/src/commands/sign.js +306 -0
- package/dist/src/commands/sign.js.map +1 -0
- package/dist/src/commands/tokens.js +36 -0
- package/dist/src/commands/tokens.js.map +1 -0
- package/dist/src/commands/wallet.js +596 -44
- package/dist/src/commands/wallet.js.map +1 -1
- package/dist/src/commands/watch.js +259 -0
- package/dist/src/commands/watch.js.map +1 -0
- package/dist/src/help/examples.js +538 -8
- package/dist/src/help/examples.js.map +1 -1
- package/dist/src/package-version.js +53 -0
- package/dist/src/package-version.js.map +1 -0
- package/dist/src/progress.js +19 -0
- package/dist/src/progress.js.map +1 -1
- package/dist/src/render/balance.js +19 -2
- package/dist/src/render/balance.js.map +1 -1
- package/dist/src/validate.js +55 -9
- package/dist/src/validate.js.map +1 -1
- package/package.json +24 -18
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as clack from '@clack/prompts';
|
|
2
|
-
import {
|
|
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 (
|
|
24
|
-
* accounts use) must route through this rather than printResult
|
|
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
|
-
|
|
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 <
|
|
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
|
|
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 || !
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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: () =>
|
|
253
|
+
token: () => held.length
|
|
208
254
|
? clack.select({
|
|
209
255
|
message: `Token? (held on ${chain.name})`,
|
|
210
|
-
|
|
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: '
|
|
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
|
-
|
|
277
|
+
tokenRaw = answers.token;
|
|
226
278
|
recipient = answers.recipient;
|
|
227
279
|
}
|
|
228
280
|
const amount = parseAmount(amountRaw);
|
|
229
|
-
if (!
|
|
230
|
-
throw invalidArgs('--token is required.', 'A
|
|
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
|
-
|
|
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} ${
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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')
|