@agentgrant.cash/cli 1.2.0 → 1.3.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.
@@ -121,15 +121,38 @@ export async function loadDisplayPrefs(ctx) {
121
121
  return DEFAULT_PREFS;
122
122
  }
123
123
  }
124
+ /**
125
+ * Build the money-side SUCCESS envelope. The whole CLI — money and agent sides —
126
+ * speaks one contract: `{ ok: true, ...data }`. A plain object is spread in; an
127
+ * array / primitive is nested under `data` (spreading an array would splay its
128
+ * indices as keys). A redundant top-level `success` marker is dropped so `ok` is
129
+ * the single source of truth (closes the "trade cmds use {success:true}" gap).
130
+ * Pure + exported for unit testing.
131
+ */
132
+ export function buildOutPayload(data) {
133
+ if (data && typeof data === "object" && !Array.isArray(data)) {
134
+ const { success: _drop, ...rest } = data;
135
+ void _drop;
136
+ return { ok: true, ...rest };
137
+ }
138
+ return { ok: true, data };
139
+ }
140
+ /** Build the money-side FAILURE envelope: `{ ok:false, error:{code,message,recoverable} }`. */
141
+ export function buildErrorPayload(msg, opts) {
142
+ return {
143
+ ok: false,
144
+ error: { code: opts?.code ?? "ERROR", message: msg, recoverable: opts?.recoverable ?? false },
145
+ };
146
+ }
124
147
  export function out(ctx, human, data) {
125
148
  if (ctx.json)
126
- console.log(JSON.stringify(data, null, 2));
149
+ console.log(JSON.stringify(buildOutPayload(data), null, 2));
127
150
  else
128
151
  console.log(human);
129
152
  }
130
- export function fail(msg, json) {
153
+ export function fail(msg, json, opts) {
131
154
  if (json)
132
- console.error(JSON.stringify({ success: false, error: msg }));
155
+ console.error(JSON.stringify(buildErrorPayload(msg, opts)));
133
156
  else
134
157
  console.error(pc.red(`✖ ${msg}`));
135
158
  process.exit(1);
@@ -10,24 +10,26 @@ export function registerBorrow(program) {
10
10
  };
11
11
  program.command('borrow').description('Borrow cash against an asset')
12
12
  .requiredOption('--against <asset>').requiredOption('--amount <cash>')
13
+ .option('--usd', 'interpret --amount as US dollars, ignoring your display currency')
13
14
  .option('--no-wait', 'return immediately without waiting for confirmation')
14
15
  .action(async (opts, cmd) => {
15
16
  const ctx = buildCtx(cmd);
16
17
  requireAuth(ctx);
17
18
  const r = await resolver(ctx);
18
19
  const collateral = r.resolve(opts.against).symbol;
19
- const m = await resolveCash(ctx, opts.amount, CASH_DECIMALS);
20
+ const m = await resolveCash(ctx, opts.amount, CASH_DECIMALS, { usd: !!opts.usd });
20
21
  await exec(ctx, () => ctx.client.borrow({ collateral, loan: CASH, amount: m.usdString }), opts.wait !== false, `Borrow ${describeCashResolution(m)} against ${opts.against}`);
21
22
  });
22
23
  program.command('repay').description('Repay borrowed cash')
23
24
  .requiredOption('--against <asset>').requiredOption('--amount <cash>')
25
+ .option('--usd', 'interpret --amount as US dollars, ignoring your display currency')
24
26
  .option('--no-wait', 'return immediately without waiting for confirmation')
25
27
  .action(async (opts, cmd) => {
26
28
  const ctx = buildCtx(cmd);
27
29
  requireAuth(ctx);
28
30
  const r = await resolver(ctx);
29
31
  const collateral = r.resolve(opts.against).symbol;
30
- const m = await resolveCash(ctx, opts.amount, CASH_DECIMALS);
32
+ const m = await resolveCash(ctx, opts.amount, CASH_DECIMALS, { usd: !!opts.usd });
31
33
  await exec(ctx, () => ctx.client.repay({ collateral, loan: CASH, amount: m.usdString }), opts.wait !== false, `Repay ${describeCashResolution(m)} against ${opts.against}`);
32
34
  });
33
35
  program.command('add-collateral').description('Add collateral')
@@ -52,6 +54,7 @@ export function registerBorrow(program) {
52
54
  });
53
55
  program.command('leverage').description('Add collateral and borrow cash in one step')
54
56
  .requiredOption('--asset <asset>').requiredOption('--deposit <n>').requiredOption('--borrow <cash>')
57
+ .option('--usd', 'interpret --borrow as US dollars, ignoring your display currency')
55
58
  .option('--no-wait', 'return immediately without waiting for confirmation')
56
59
  .action(async (opts, cmd) => {
57
60
  const ctx = buildCtx(cmd);
@@ -59,7 +62,7 @@ export function registerBorrow(program) {
59
62
  const r = await resolver(ctx);
60
63
  const a = r.resolve(opts.asset);
61
64
  // --deposit is a quantity of the collateral asset; --borrow is cash (currency-aware).
62
- const borrow = await resolveCash(ctx, opts.borrow, CASH_DECIMALS);
65
+ const borrow = await resolveCash(ctx, opts.borrow, CASH_DECIMALS, { usd: !!opts.usd });
63
66
  await exec(ctx, () => ctx.client.supplyAndBorrow({ collateral: a.symbol, loan: CASH, collateralAmount: assertDecimal(opts.deposit, a.decimals), borrowAmount: borrow.usdString }), opts.wait !== false, `Leverage ${opts.asset} (borrow ${describeCashResolution(borrow)})`);
64
67
  });
65
68
  program.command('close').description('Close a loan (repay all + withdraw collateral)')
@@ -4,10 +4,20 @@ import { describeCashResolution, formatMoney, displayDescriptor, fetchPusdBalanc
4
4
  const PREDICTION_DECIMALS = 6;
5
5
  /** Friendly message shown when the betting path isn't enabled yet (503). */
6
6
  const BETTING_DISABLED_MSG = 'Prediction betting isn\'t available yet — market browsing is read-only for now.';
7
- /** True if an error is the backend "trading not configured" 503. Exported for unit testing. */
7
+ /**
8
+ * True if an error means the betting path is unavailable, so BOTH `--side yes`
9
+ * and `--side no` surface the same friendly line. The configured-off state comes
10
+ * back as 503 / POLYMARKET_TRADING_NOT_CONFIGURED, but the disabled `no` path was
11
+ * observed returning a bare upstream 502 (and 504 is the same class of gateway
12
+ * outage) — treat those as unavailable too rather than leaking a raw "HTTP 502".
13
+ * Exported for unit testing.
14
+ */
8
15
  export function isTradingDisabled(err) {
9
16
  const e = err;
10
- return e?.status === 503 || e?.code === 'POLYMARKET_TRADING_NOT_CONFIGURED';
17
+ return (e?.status === 502 ||
18
+ e?.status === 503 ||
19
+ e?.status === 504 ||
20
+ e?.code === 'POLYMARKET_TRADING_NOT_CONFIGURED');
11
21
  }
12
22
  /** Render a market list into human-readable lines. Exported for unit testing. */
13
23
  export function formatMarkets(markets, count) {
@@ -1,4 +1,4 @@
1
- import { buildCtx, requireAuth, resolver, reportTx, resolveCash, resolveCashTargetQty, out, fail } from '../money-helpers.js';
1
+ import { buildCtx, requireAuth, resolver, reportTx, resolveCash, resolveCashTargetQty, forceUsdAmount, out, fail } from '../money-helpers.js';
2
2
  import { assertDecimal, describeCashResolution } from '../../lib/index.js';
3
3
  /**
4
4
  * Resolve the sell-side `sellAmount` (always in `from`-token units) for a trade.
@@ -17,12 +17,13 @@ async function resolveSellSide(ctx, from, spec) {
17
17
  fail('Use --amount for cash; --for is for selling an asset by cash value.', ctx.json);
18
18
  if (!spec.amount)
19
19
  fail('Missing --amount (cash to spend).', ctx.json);
20
- const m = await resolveCash(ctx, spec.amount, from.decimals);
20
+ const m = await resolveCash(ctx, spec.amount, from.decimals, { usd: spec.usd });
21
21
  return { sellAmount: m.usdString, note: describeCashResolution(m) };
22
22
  }
23
23
  // Asset source, cash target → size the quantity from the live price.
24
24
  if (spec.forCash) {
25
- const r = await resolveCashTargetQty(ctx, from, spec.forCash);
25
+ // `--usd` forces the cash target to dollars (prefix "$") before sizing.
26
+ const r = await resolveCashTargetQty(ctx, from, spec.usd ? forceUsdAmount(spec.forCash) : spec.forCash);
26
27
  return {
27
28
  sellAmount: r.quantity,
28
29
  note: `${describeCashResolution(r)} of ${from.friendly} (≈ ${r.quantity} ${from.friendly})`,
@@ -40,13 +41,14 @@ function oneOfAmountOrFor(ctx, opts) {
40
41
  fail('Pass either --amount (a quantity) or --for (a cash value), not both.', ctx.json);
41
42
  if (!opts.amount && !opts.for)
42
43
  fail('Specify --amount <quantity> or --for <cash value>.', ctx.json);
43
- return { amount: opts.amount, forCash: opts.for };
44
+ return { amount: opts.amount, forCash: opts.for, usd: opts.usd };
44
45
  }
45
46
  export function registerTrade(program) {
46
47
  program.command('quote').description('Preview a trade (no execution)')
47
48
  .requiredOption('--from <asset>').requiredOption('--to <asset>')
48
49
  .option('--amount <n>', 'quantity of the "from" asset')
49
50
  .option('--for <cash>', 'cash value to sell (e.g. 100, "$50", "₹100")')
51
+ .option('--usd', 'interpret a cash --for value as US dollars, ignoring your display currency')
50
52
  .action(async (opts, cmd) => {
51
53
  const ctx = buildCtx(cmd);
52
54
  requireAuth(ctx);
@@ -72,23 +74,32 @@ export function registerTrade(program) {
72
74
  };
73
75
  program.command('buy <asset>').description('Buy an asset with cash')
74
76
  .requiredOption('--amount <cash>', 'amount of cash to spend (your currency; prefix $/₹ to force one)')
77
+ .option('--usd', 'interpret --amount as US dollars, ignoring your display currency')
75
78
  .option('--no-wait', 'return immediately without waiting for on-chain confirmation')
76
- .action((asset, opts, cmd) => trade(cmd, 'cash', asset, { amount: opts.amount }, opts.wait !== false));
79
+ .action((asset, opts, cmd) => trade(cmd, 'cash', asset, { amount: opts.amount, usd: opts.usd }, opts.wait !== false));
77
80
  program.command('sell <asset>').description('Sell an asset — by quantity (--amount) or by cash value (--for)')
78
81
  .option('--amount <n>', 'quantity of the asset to sell (e.g. 0.1 bitcoin)')
79
82
  .option('--for <cash>', 'cash value to sell instead (e.g. 100, "$50", "₹100")')
83
+ .option('--usd', 'interpret a cash --for value as US dollars, ignoring your display currency')
80
84
  .option('--no-wait', 'return immediately without waiting for on-chain confirmation')
81
85
  .action((asset, opts, cmd) => {
82
86
  const ctx = buildCtx(cmd);
83
- trade(cmd, asset, 'cash', oneOfAmountOrFor(ctx, opts), opts.wait !== false);
87
+ // MUST return the promise: commander's parseAsync(...).catch() only routes a
88
+ // rejection through the friendly {ok:false} envelope if the action returns it.
89
+ // Dropping it (a brace body without `return`) orphans the rejection → raw
90
+ // Node stack trace (the reported `convert`/`sell` crash on PAIR_NOT_SUPPORTED).
91
+ return trade(cmd, asset, 'cash', oneOfAmountOrFor(ctx, opts), opts.wait !== false);
84
92
  });
85
93
  program.command('convert').description('Convert one asset into another — by quantity (--amount) or cash value (--for)')
86
94
  .requiredOption('--from <asset>').requiredOption('--to <asset>')
87
95
  .option('--amount <n>', 'quantity of the "from" asset')
88
96
  .option('--for <cash>', 'cash value of "from" to convert (e.g. 100, "$50", "₹100")')
97
+ .option('--usd', 'interpret a cash --for value as US dollars, ignoring your display currency')
89
98
  .option('--no-wait', 'return immediately without waiting for on-chain confirmation')
90
99
  .action((opts, cmd) => {
91
100
  const ctx = buildCtx(cmd);
92
- trade(cmd, opts.from, opts.to, oneOfAmountOrFor(ctx, opts), opts.wait !== false);
101
+ // Return the promise (see `sell` above) without it a rejected convert
102
+ // (e.g. PAIR_NOT_SUPPORTED) escapes as an unhandled rejection / raw stack.
103
+ return trade(cmd, opts.from, opts.to, oneOfAmountOrFor(ctx, opts), opts.wait !== false);
93
104
  });
94
105
  }
@@ -8,6 +8,16 @@ import { AppError } from "./errors.js";
8
8
  * a driving agent knows whether to retry (see SKILL.md error playbook).
9
9
  */
10
10
  const REQUEST_TIMEOUT_MS = 60_000;
11
+ /**
12
+ * 4xx codes that are nonetheless safe to retry, per the SKILL error playbook.
13
+ * Without this, recoverability was inferred purely from `status >= 500`, so a
14
+ * free-reject like SCHEMA_VALIDATION_FAILED (422) was wrongly tagged
15
+ * `recoverable:false` even though the fix is "correct the body and retry".
16
+ */
17
+ const RECOVERABLE_CODES = new Set([
18
+ "SCHEMA_VALIDATION_FAILED", // free reject — fix the body, retry
19
+ "X402_FACILITATOR_TIMEOUT", // transient settlement timeout — retry
20
+ ]);
11
21
  export class AgentClient {
12
22
  base;
13
23
  apiKey;
@@ -56,10 +66,14 @@ export class AgentClient {
56
66
  throw new AppError(`No Grant Cash agent API at ${this.base} (HTTP ${res.status}) — check GRANTCASH_AGENT_URL.`, { status: res.status, code: "BACKEND_UNREACHABLE", recoverable: false });
57
67
  }
58
68
  if (!res.ok || json?.error?.code) {
69
+ const code = json?.error?.code ?? `HTTP_${res.status}`;
70
+ // Honor an explicit backend `recoverable` if present; otherwise infer it —
71
+ // 5xx (transient) OR a known-recoverable 4xx code (e.g. a free reject).
72
+ const recoverable = json?.error?.recoverable ?? (res.status >= 500 || RECOVERABLE_CODES.has(code));
59
73
  throw new AppError(json?.error?.message ?? `Request failed (${res.status})`, {
60
74
  status: res.status,
61
- code: json?.error?.code ?? `HTTP_${res.status}`,
62
- recoverable: res.status >= 500,
75
+ code,
76
+ recoverable,
63
77
  });
64
78
  }
65
79
  return json;
@@ -70,13 +70,23 @@ export class PerfolioClient {
70
70
  if (res.status === 428)
71
71
  throw new NoSessionError();
72
72
  if (!res.ok || json.success === false) {
73
- const err = new Error(json.error || json.message || `HTTP ${res.status}`);
73
+ // The `error` field comes in two shapes across backends: a flat string, or a
74
+ // nested `{ code, message, recoverable }` object (kyc / beneficiaries / fiat).
75
+ // Normalize BOTH — never let an object reach `new Error()`, which would
76
+ // stringify it to the useless "[object Object]" the report flagged.
77
+ const rawErr = json.error;
78
+ const nested = rawErr && typeof rawErr === "object"
79
+ ? rawErr
80
+ : undefined;
81
+ const errString = typeof rawErr === "string" ? rawErr : undefined;
82
+ const message = nested?.message || errString || json.message || `HTTP ${res.status}`;
83
+ const err = new Error(message);
74
84
  err.status = res.status;
75
- // Prefer the backend's machine code (e.g. 'MIN_ORDER_NOT_MET'); fall back to
76
- // the legacy convention where the `error` field carried a code-like string
77
- // (e.g. 'GRANT_REQUIRED', 'REGISTRY_UNAVAILABLE') for endpoints not yet on
78
- // the structured contract.
79
- err.code = json.code ?? json.error;
85
+ // Prefer the backend's machine code (e.g. 'MIN_ORDER_NOT_MET' / a nested
86
+ // error.code); fall back to the legacy convention where the `error` field
87
+ // itself carried a code-like string (e.g. 'GRANT_REQUIRED').
88
+ err.code = json.code ?? nested?.code ?? errString;
89
+ err.recoverable = json.recoverable ?? nested?.recoverable;
80
90
  err.details = json.details;
81
91
  throw err;
82
92
  }
@@ -251,7 +261,12 @@ export class PerfolioClient {
251
261
  hlTpsl(b) {
252
262
  return this.post(this.urls.api, '/hl/tpsl', b);
253
263
  }
254
- hlCancelOrder(b) { return this.post(this.urls.api, '/hl/cancel-order', b); }
264
+ hlCancelOrder(b) {
265
+ // The backend route reads `req.body.orderId`; send BOTH `orderId` (canonical)
266
+ // and `oid` (alias) so a cancel works regardless of which name the deployed
267
+ // backend expects — fixes the "Missing orderId" the report hit.
268
+ return this.post(this.urls.api, '/hl/cancel-order', { orderId: b.oid, oid: b.oid, asset: b.asset });
269
+ }
255
270
  /**
256
271
  * Autonomous USDT → Hyperliquid deposit. Hits POST /tx/bridge-deposit, which
257
272
  * builds the USDT-approve + Across bridge calls and executes them via the
@@ -84,11 +84,18 @@ export class MoneyClient {
84
84
  if (res.status === 428)
85
85
  throw new NoSessionError();
86
86
  if (!res.ok || json.success === false) {
87
- throw new AppError(json.error || json.message || `HTTP ${res.status}`, {
87
+ // `error` may be a flat string or a nested { code, message, recoverable }
88
+ // object — normalize both so an object never becomes "[object Object]".
89
+ const rawErr = json.error;
90
+ const nested = rawErr && typeof rawErr === "object"
91
+ ? rawErr
92
+ : undefined;
93
+ const errString = typeof rawErr === "string" ? rawErr : undefined;
94
+ throw new AppError(nested?.message || errString || json.message || `HTTP ${res.status}`, {
88
95
  status: res.status,
89
- code: json.code ?? json.error,
96
+ code: json.code ?? nested?.code ?? errString,
90
97
  details: json.details,
91
- recoverable: res.status >= 500,
98
+ recoverable: json.recoverable ?? nested?.recoverable ?? res.status >= 500,
92
99
  });
93
100
  }
94
101
  return json.data;
@@ -77,6 +77,34 @@ function roundToDecimalString(n, decimals) {
77
77
  export function defaultInputCurrency(prefs) {
78
78
  return usesLocalCurrency(prefs) ? prefs.currency.toUpperCase() : 'USD';
79
79
  }
80
+ /**
81
+ * Per-transaction USD ceiling — a fat-finger guard, NOT a product limit. Combined
82
+ * with the display-currency default (a bare "100" can mean a large local amount),
83
+ * an un-bounded input is a real over-spend risk, so anything above this is refused
84
+ * with a recoverable error rather than quietly accepted.
85
+ */
86
+ export const MAX_CASH_USD = 10_000_000;
87
+ /** Tag an error with a machine code + recoverable flag for the {ok:false} envelope. */
88
+ function amountError(message, code) {
89
+ const e = new Error(message);
90
+ e.code = code;
91
+ e.recoverable = true; // the user can simply re-enter a sane amount
92
+ return e;
93
+ }
94
+ /**
95
+ * Reject amounts that resolved to dust (rounds to $0 → opaque SWAP_QUOTE_FAILED
96
+ * downstream) or to an implausibly large value (fat-finger). `describe` renders
97
+ * the user's own amount for the message.
98
+ */
99
+ export function assertUsdInBounds(usd, describe) {
100
+ if (!(usd > 0)) {
101
+ throw amountError(`${describe()} is too small to transact — please enter a larger amount.`, 'AMOUNT_TOO_SMALL');
102
+ }
103
+ if (usd > MAX_CASH_USD) {
104
+ throw amountError(`${describe()} exceeds the $${MAX_CASH_USD.toLocaleString('en-US')} per-transaction safety limit. ` +
105
+ `If that's really intended, split it into smaller amounts.`, 'AMOUNT_TOO_LARGE');
106
+ }
107
+ }
80
108
  /**
81
109
  * Resolve a user-entered cash amount into a USD decimal string for the backend.
82
110
  *
@@ -90,6 +118,10 @@ export async function resolveCashToUsd(raw, prefs, decimals, fetchRate) {
90
118
  const sourceCurrency = (parsed.currency ?? defaultInputCurrency(prefs)).toUpperCase();
91
119
  if (sourceCurrency === 'USD') {
92
120
  const usdString = assertDecimal(roundToDecimalString(parsed.amount, decimals), decimals);
121
+ // Guard BEFORE returning: a sub-cent USD dust amount rounds to "0" here and
122
+ // would otherwise reach the backend as an opaque SWAP_QUOTE_FAILED; a giant
123
+ // amount is a fat-finger. The rounded value catches both (0 → too-small).
124
+ assertUsdInBounds(Number(usdString), () => fmtCurrency(parsed.amount, 'USD'));
93
125
  return {
94
126
  usd: Number(usdString),
95
127
  usdString,
@@ -112,9 +144,9 @@ export async function resolveCashToUsd(raw, prefs, decimals, fetchRate) {
112
144
  }
113
145
  const usd = parsed.amount / rate;
114
146
  const usdString = roundToDecimalString(usd, decimals);
115
- if (Number(usdString) <= 0) {
116
- throw new Error(`${parsed.amount} ${sourceCurrency} is too small to transact (≈ $0 at the current rate).`);
117
- }
147
+ // Same dust + fat-finger guard as the USD branch, but described in the user's
148
+ // own currency (e.g. "₹50.00 is too small…").
149
+ assertUsdInBounds(Number(usdString), () => fmtCurrency(parsed.amount, sourceCurrency));
118
150
  return {
119
151
  usd: Number(usdString),
120
152
  usdString: assertDecimal(usdString, decimals),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentgrant.cash/cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Grant Cash — one CLI for your money (gold) and your agent (pay-per-use services). Routes to the Perfolio backend and the Agent-mode backend behind a single, plain-language surface.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,13 +34,17 @@ Throughout this skill, **`grant <command>` is shorthand for `npx @agentgrant.cas
34
34
  ## Output (for assistants driving this CLI)
35
35
 
36
36
  - Output is **JSON** when stdout is not a TTY (you captured it) or with `--json`; pretty for people otherwise.
37
- - Success → `{"ok":true, ...}`. Failure → `{"ok":false,"error":{"code","message","recoverable"}}`.
37
+ - Success → `{"ok":true, ...}`. Failure → `{"ok":false,"error":{"code","message","recoverable"}}`. This is uniform across **every** command — Investments and Spending alike — so always key off `ok`, never a per-command field.
38
38
  - `recoverable:true` → safe to retry. `recoverable:false` → STOP; don't retry blindly.
39
39
 
40
40
  ## Money amounts (important)
41
41
 
42
42
  Every **cash** amount is in the user's display currency — a bare `50` means ₹50 for an INR user, $50 for a USD user. Force a currency by prefixing: `"$50"`, `"₹100"`, `"100 EUR"`. This applies everywhere cash is entered: `buy --amount`, `borrow --amount`, prediction bets/deposits, `withdraw`, and so on. Asset *quantities* (e.g. `sell bitcoin --amount 0.1`) are not currency.
43
43
 
44
+ - **Force US dollars with `--usd`.** Every cash command accepts `--usd` to interpret a bare amount as dollars regardless of display currency: `buy`, `sell --for`, `convert --for`, `quote --for`, `borrow`, `repay`, `leverage --borrow`, `hl deposit/withdraw/open`, and `polymarket bet/deposit`. (`--usd` + an already-prefixed amount like `"₹50"` leaves the explicit currency untouched.)
45
+ - **The CLI echoes what it interpreted.** When a conversion happens, responses show both sides — e.g. `"₹50.00 (≈ $0.60)"` — so the display-currency default never silently surprises anyone. Relay that to the user.
46
+ - **Bounds.** A dust amount that rounds to ~$0 is refused cleanly (`AMOUNT_TOO_SMALL`, recoverable — enter more), and an implausibly large amount is refused as a fat-finger guard (`AMOUNT_TOO_LARGE`, recoverable).
47
+
44
48
  ## Workflow
45
49
 
46
50
  1. `grant status` — confirm you're connected (both Investments and Spending).
@@ -126,6 +130,12 @@ Every **cash** amount is in the user's display currency — a bare `50` means
126
130
  - `grant schema <slug>` — the exact pay contract for a catalogued service (no charge).
127
131
  - `grant fetch <url> [-m POST] [-b '<json>'] [--price <amount>] [--asset USDC|USDT]` (alias `pay`) — pay for and run a service.
128
132
  - `grant transfer <recipient> <amount>` — send money to an address.
133
+
134
+ **Calling a service with `grant fetch` — what to know:**
135
+ - **The response body is base64-encoded.** `result.upstreamResponse.body` is base64 — decode it to read the service's JSON/text. (It's encoded so binary or non-JSON responses survive transit intact.)
136
+ - **POST-with-body is the most reliable shape.** For a POST service, build the JSON `-b '<json>'` from the contract `grant check <url>` / `grant schema <slug>` returns.
137
+ - **GET services with required query params:** run `grant check <url>` first; if the price can't be inferred, pass it explicitly with `--price <amount>` (the value comes from `check`). Put the query params in the URL itself; do **not** send them in `-b` (a GET has no body, so that fails `SCHEMA_VALIDATION_FAILED`).
138
+ - **Payment guardrails (so you don't hit them blind):** `fetch` only pays **https** origins that are **public** — `http://`, `localhost`, `127.0.0.1`, LAN/private IPs and metadata IPs are blocked (anti-SSRF). And the origin must be in the catalog or one you've added via `grant discover` — an un-allowlisted origin returns `MARKETPLACE_NOT_ALLOWED`. A failed call that never settled does not charge you.
129
139
  - `grant spending deposit --amount <cash>` (aliases `add`, `top-up`) — move cash from the Investments side into the Spending balance (the agent's pay-per-use money). Amount is in the user's currency — a bare `50` means ₹50 for an INR user, $50 for a USD user. Funds land a few minutes after submitting. There is no reverse: Spending is funded from cash, not the other way around.
130
140
  - `grant redeem <code>` — claim an invite or bonus credit toward spending.
131
141
  - `grant upgrade` — get a one-time link to secure a full account and add spending funds.
@@ -153,6 +163,9 @@ Always state amounts in the user's display currency (never raw USD/USDT or chain
153
163
  | `GUARDRAIL_DENIED` | no | STOP. This is outside the limits the user set — don't retry. Tell the user. |
154
164
  | `SIGNER_REVOKED` | no | STOP. Spending was stopped — re-authorize with `grant login`. |
155
165
  | `SCHEMA_VALIDATION_FAILED` | yes | Free reject. Run `grant check <url>`, fix the `--body`, then retry. |
166
+ | `MARKETPLACE_NOT_ALLOWED` | no | Origin isn't catalogued/allowlisted — add it with `grant discover <origin>` first (must be public https). |
167
+ | `AMOUNT_TOO_SMALL` | yes | Amount rounds to ~$0 — enter a larger amount. |
168
+ | `AMOUNT_TOO_LARGE` | yes | Above the per-transaction safety limit — split into smaller amounts (or re-check the currency). |
156
169
  | `BACKEND_UNREACHABLE` | no | Wrong or unreachable address — check the connection with `grant config show`. |
157
170
  | `NETWORK` | yes | Temporary — retry once. |
158
171