@agentgrant.cash/cli 1.0.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 (45) hide show
  1. package/.env.example +21 -0
  2. package/README.md +48 -0
  3. package/dist/cli/commands/agent.js +139 -0
  4. package/dist/cli/commands/auth.js +248 -0
  5. package/dist/cli/commands/meta.js +77 -0
  6. package/dist/cli/commands/money.js +85 -0
  7. package/dist/cli/commands/portfolio.js +224 -0
  8. package/dist/cli/index.js +94 -0
  9. package/dist/cli/money-helpers.js +189 -0
  10. package/dist/cli/perfolio-commands/account.js +272 -0
  11. package/dist/cli/perfolio-commands/borrow.js +75 -0
  12. package/dist/cli/perfolio-commands/discover.js +30 -0
  13. package/dist/cli/perfolio-commands/earn.js +193 -0
  14. package/dist/cli/perfolio-commands/hyperliquid.js +408 -0
  15. package/dist/cli/perfolio-commands/loans.js +34 -0
  16. package/dist/cli/perfolio-commands/market.js +76 -0
  17. package/dist/cli/perfolio-commands/polymarket.js +304 -0
  18. package/dist/cli/perfolio-commands/session.js +19 -0
  19. package/dist/cli/perfolio-commands/trade.js +94 -0
  20. package/dist/cli/perfolio-commands/tx.js +22 -0
  21. package/dist/lib/agent-client.js +166 -0
  22. package/dist/lib/agent-device.js +173 -0
  23. package/dist/lib/amounts.js +45 -0
  24. package/dist/lib/assets.js +47 -0
  25. package/dist/lib/client.js +284 -0
  26. package/dist/lib/config.js +46 -0
  27. package/dist/lib/context.js +35 -0
  28. package/dist/lib/currency.js +91 -0
  29. package/dist/lib/device.js +163 -0
  30. package/dist/lib/errors.js +59 -0
  31. package/dist/lib/format.js +22 -0
  32. package/dist/lib/index.js +24 -0
  33. package/dist/lib/kyc-status.js +28 -0
  34. package/dist/lib/money-client.js +157 -0
  35. package/dist/lib/money-input.js +176 -0
  36. package/dist/lib/output.js +45 -0
  37. package/dist/lib/polygon-balance.js +125 -0
  38. package/dist/lib/portfolio-format.js +224 -0
  39. package/dist/lib/relay.js +19 -0
  40. package/dist/lib/sign.js +29 -0
  41. package/dist/lib/tx-wait.js +35 -0
  42. package/dist/lib/types.js +10 -0
  43. package/dist/lib/verify.js +38 -0
  44. package/package.json +37 -0
  45. package/skills/grant-cash/SKILL.md +152 -0
@@ -0,0 +1,408 @@
1
+ import { buildCtx, requireAuth, runSignFlow, out, fail, resolveCash, loadDisplayPrefs } from '../money-helpers.js';
2
+ import { describeCashResolution, formatMoney } from '../../lib/index.js';
3
+ import { waitForTx } from '../../lib/verify.js';
4
+ /**
5
+ * Hyperliquid's minimum order value ($10 notional). Used for a client-side
6
+ * pre-submit warning; the backend remains authoritative and re-validates.
7
+ */
8
+ export const HL_MIN_ORDER_USD = 10;
9
+ /**
10
+ * Pre-submit minimum-order check. Returns a currency-aware warning string when
11
+ * `sizeUsd` is below Hyperliquid's $10 minimum, else null. Exported for testing.
12
+ *
13
+ * Renders the minimum in the user's display currency (e.g. ₹800 for an INR user
14
+ * at rate 80) so CLI traders see an amount in their own terms rather than a bare
15
+ * "$10" — the gap that left CLI users guessing why orders were rejected.
16
+ */
17
+ export function minOrderHint(sizeUsd, prefs, minUsd = HL_MIN_ORDER_USD) {
18
+ if (sizeUsd >= minUsd)
19
+ return null;
20
+ return `Minimum Hyperliquid order is ${formatMoney(minUsd, prefs)}. Increase --size and try again.`;
21
+ }
22
+ /**
23
+ * Human-readable Hyperliquid account summary. Balance/equity/margin come back as
24
+ * USD strings; localize them. Exported for testing. Shape (from backend
25
+ * GET /api/hl/status): { isSetup, isApproved, balance, equity, availableMargin, marginUsed }.
26
+ */
27
+ export function formatHlStatus(data, prefs) {
28
+ const num = (v) => {
29
+ const n = Number(v);
30
+ return Number.isFinite(n) ? n : 0;
31
+ };
32
+ if (!data.isApproved) {
33
+ return data.isSetup
34
+ ? 'Hyperliquid trading: set up but not approved yet — run `grant hl setup` to finish.'
35
+ : 'Hyperliquid trading: not enabled yet — run `grant hl setup` to start trading perps.';
36
+ }
37
+ const equity = num(data.equity ?? data.balance);
38
+ return [
39
+ 'Hyperliquid (perps trading)',
40
+ ` Account value: ${formatMoney(equity, prefs)}`,
41
+ ` Available to trade: ${formatMoney(num(data.availableMargin), prefs)}`,
42
+ ` Margin in use: ${formatMoney(num(data.marginUsed), prefs)}`,
43
+ ].join('\n');
44
+ }
45
+ /** Relay terminal states for an HL→Ethereum withdrawal. */
46
+ const WD_OK = new Set(['success', 'filled', 'completed', 'done']);
47
+ const WD_BAD = new Set(['failure', 'failed', 'refund', 'expired', 'cancelled']);
48
+ /**
49
+ * Poll a HL→Ethereum withdrawal to a terminal state and report it honestly:
50
+ * success (with the Ethereum delivery tx), failed/refunded, or still-settling
51
+ * (re-checkable, never a dead end). Used by both `hl withdraw` and `hl withdraw-status`.
52
+ */
53
+ async function pollWithdrawStatus(ctx, requestId, expected) {
54
+ const tail = expected ? ` (~${expected} USDT)` : '';
55
+ const start = Date.now();
56
+ let last = 'pending';
57
+ while (Date.now() - start < 240_000) {
58
+ try {
59
+ const s = await ctx.client.hlWithdrawStatus(requestId);
60
+ last = (s?.status || 'pending').toLowerCase();
61
+ const outTx = s?.outTxHashes?.[0];
62
+ if (WD_OK.has(last)) {
63
+ out(ctx, `✓ Withdrawal complete${tail} — cash delivered to your wallet on Ethereum.${outTx ? `\n tx: ${outTx}` : ''}`, { success: true, status: last, requestId, outTxHash: outTx });
64
+ return;
65
+ }
66
+ if (WD_BAD.has(last)) {
67
+ fail(`Withdrawal ${last}. Funds returned to your Hyperliquid balance.`, ctx.json);
68
+ return;
69
+ }
70
+ }
71
+ catch {
72
+ /* transient (relay not indexed yet / blip) — keep polling */
73
+ }
74
+ await new Promise((r) => setTimeout(r, 4000));
75
+ }
76
+ out(ctx, `Withdrawal still settling${tail}. Re-check with \`grant hl withdraw-status ${requestId}\`.`, { submitted: true, confirmed: false, status: last, requestId });
77
+ }
78
+ /** Poll HL status until the account equity reaches >= minUsd, or timeout. */
79
+ async function waitForHlBalance(ctx, minUsd, timeoutMs) {
80
+ const start = Date.now();
81
+ while (Date.now() - start < timeoutMs) {
82
+ try {
83
+ const s = (await ctx.client.hlStatus());
84
+ if (parseFloat(s?.balance ?? '0') >= minUsd)
85
+ return true;
86
+ }
87
+ catch {
88
+ /* transient — keep polling */
89
+ }
90
+ await new Promise((r) => setTimeout(r, 5000));
91
+ }
92
+ return false;
93
+ }
94
+ /**
95
+ * Hyperliquid perpetual trading.
96
+ *
97
+ * Reads + trades are AUTONOMOUS — the backend signs with a server-held,
98
+ * encrypted per-user agent key (HL's native API-wallet). The only step that
99
+ * needs the user is the one-time agent approval (`hl setup`), which signs an
100
+ * EIP-712 ApproveAgent action in the browser via the sign relay. After that,
101
+ * open/close/leverage/tpsl all run with no further interaction.
102
+ */
103
+ export function registerHyperliquid(program) {
104
+ const hl = program.command('hl').description('Hyperliquid perpetual trading (any market — stocks, crypto, commodities, fx, indices)');
105
+ hl.command('status').description('Agent-approval state, balance and open positions')
106
+ .action(async (_o, cmd) => {
107
+ const ctx = buildCtx(cmd);
108
+ requireAuth(ctx);
109
+ const data = await ctx.client.hlStatus();
110
+ if (ctx.json) {
111
+ out(ctx, '', data);
112
+ return;
113
+ }
114
+ const prefs = await loadDisplayPrefs(ctx);
115
+ out(ctx, formatHlStatus(data, prefs), data);
116
+ });
117
+ hl.command('positions').description('Open perpetual positions')
118
+ .action(async (_o, cmd) => {
119
+ const ctx = buildCtx(cmd);
120
+ requireAuth(ctx);
121
+ const data = await ctx.client.hlPositions();
122
+ out(ctx, JSON.stringify(data, null, 2), data);
123
+ });
124
+ hl.command('orders').description('Open orders')
125
+ .action(async (_o, cmd) => {
126
+ const ctx = buildCtx(cmd);
127
+ requireAuth(ctx);
128
+ const data = await ctx.client.hlOrders();
129
+ out(ctx, JSON.stringify(data, null, 2), data);
130
+ });
131
+ hl.command('history').description('Trade / fill history')
132
+ .option('--limit <n>', 'how many records to return (default 50)', (v) => parseInt(v, 10))
133
+ .action(async (opts, cmd) => {
134
+ const ctx = buildCtx(cmd);
135
+ requireAuth(ctx);
136
+ if (opts.limit !== undefined && !(Number.isFinite(opts.limit) && opts.limit > 0))
137
+ fail('--limit must be a positive whole number', ctx.json);
138
+ const data = await ctx.client.hlTradeHistory(opts.limit);
139
+ out(ctx, JSON.stringify(data, null, 2), data);
140
+ });
141
+ hl.command('price').description('Live mark price for any market (default GOLD)')
142
+ .option('--asset <symbol>', 'market symbol — e.g. AAPL, TSLA, BTC, GOLD. Use `hl markets` to find it.')
143
+ .action(async (opts, cmd) => {
144
+ const ctx = buildCtx(cmd);
145
+ requireAuth(ctx);
146
+ const data = await ctx.client.hlPrice(opts.asset);
147
+ out(ctx, JSON.stringify(data, null, 2), data);
148
+ });
149
+ hl.command('markets').description('Discover tradeable markets — stocks, crypto, commodities, fx, indices')
150
+ .option('--search <q>', 'filter by symbol or category (e.g. aapl, stock, gold, crypto)')
151
+ .action(async (opts, cmd) => {
152
+ const ctx = buildCtx(cmd);
153
+ requireAuth(ctx);
154
+ const data = await ctx.client.hlMarkets(opts.search);
155
+ if (ctx.json) {
156
+ out(ctx, '', data);
157
+ return;
158
+ }
159
+ const lines = data.markets
160
+ .slice(0, 60)
161
+ .map((m) => ` ${m.symbol.padEnd(10)} ${m.category.padEnd(10)} up to ${m.maxLeverage}x`);
162
+ const more = data.count > 60 ? `\n …and ${data.count - 60} more (use --search to narrow)` : '';
163
+ out(ctx, `${data.count} markets:\n${lines.join('\n')}${more}`, data);
164
+ });
165
+ // One-time: generate the agent key (autonomous) + user-approve it on HL (browser-sign).
166
+ hl.command('setup').description('Enable autonomous perp trading (funds the account first if needed, then one-time browser approval)')
167
+ .option('--fund <usdt>', 'amount of cash to move to Hyperliquid if it needs funding first')
168
+ .action(async (opts, cmd) => {
169
+ const ctx = buildCtx(cmd);
170
+ requireAuth(ctx);
171
+ let setup = await ctx.client.hlSetup();
172
+ if (setup.isApproved) {
173
+ out(ctx, 'Hyperliquid trading is already enabled.', { isApproved: true, agentAddress: setup.agentAddress });
174
+ return;
175
+ }
176
+ // FUND-THEN-ACTIVATE — mirrors the web app's FuturesSetupFunding flow.
177
+ // Hyperliquid rejects approveAgent (an L1 action) until the account holds
178
+ // funds ("Must deposit before performing actions"), so we MUST deposit
179
+ // before approving. A brand-new account reads as $0 here.
180
+ if (setup.needsDeposit) {
181
+ const min = setup.minDeposit ?? 5;
182
+ const bal = (await ctx.client.getBalance().catch(() => null));
183
+ const cash = parseFloat(bal?.balances?.USDT?.balanceFormatted ?? '0');
184
+ // --fund is a cash amount in the user's currency → resolve to USD; default
185
+ // is a USD heuristic (min of $10 / available cash).
186
+ const want = opts.fund
187
+ ? Number((await resolveCash(ctx, opts.fund, 6)).usdString)
188
+ : Math.min(10, Math.floor(cash));
189
+ if (cash < min || want < min) {
190
+ fail(`Hyperliquid needs at least $${min} on your futures account before trading can be enabled ` +
191
+ `(it currently has $${setup.hlBalance ?? '0'}). You hold $${cash.toFixed(2)} in cash. ` +
192
+ (cash < min
193
+ ? `Add funds first (\`pf deposit\` for crypto, or \`pf fiat deposit\`), then run \`pf hl setup\` again.`
194
+ : `Run \`pf hl deposit --amount ${Math.min(10, Math.floor(cash))}\` first, then \`pf hl setup\` again.`), ctx.json);
195
+ }
196
+ if (!ctx.json)
197
+ console.log(`\nHyperliquid needs funding before activation. Moving $${want} cash to Hyperliquid first…`);
198
+ // Autonomous deposit (session-key), then wait for the bridge to land.
199
+ await ctx.client.hlDeposit({ amountUsdt: String(want) });
200
+ const landed = await waitForHlBalance(ctx, min, 180_000);
201
+ if (!landed) {
202
+ out(ctx, `Deposit submitted — it can take a couple of minutes to arrive on Hyperliquid. ` +
203
+ `Once \`pf hl status\` shows the balance, run \`pf hl setup\` again to finish enabling trading.`, { submitted: true, funded: false, needsDeposit: true });
204
+ return;
205
+ }
206
+ // Re-fetch so the approval nonce / typed-data is fresh after funding.
207
+ setup = await ctx.client.hlSetup();
208
+ if (setup.isApproved) {
209
+ out(ctx, 'Hyperliquid trading is already enabled.', { isApproved: true, agentAddress: setup.agentAddress });
210
+ return;
211
+ }
212
+ }
213
+ await runSignFlow(ctx, {
214
+ label: 'Enable Hyperliquid trading',
215
+ confirmTx: false, // HL ApproveAgent is an L1 action, not an Ethereum tx
216
+ task: {
217
+ type: 'hl-approve',
218
+ signMode: 'typed',
219
+ signatureFormat: 'rsv', // Hyperliquid wants { r, s, v }
220
+ payload: setup.approvalTypedData,
221
+ executePath: '/hl/approve',
222
+ executeBody: { action: setup.approvalAction, nonce: setup.approvalAction.nonce },
223
+ preview: { agentAddress: setup.agentAddress },
224
+ },
225
+ });
226
+ });
227
+ hl.command('deposit').description('Move cash from your account into Hyperliquid (autonomous — no browser)')
228
+ .requiredOption('--amount <amount>', 'cash to move into HL — in your display currency by default; prefix $/₹ to force one, or pass --usd')
229
+ .option('--usd', 'interpret --amount as US dollars, ignoring your display currency')
230
+ .option('--no-wait', 'return immediately without waiting for the bridge tx to confirm')
231
+ .action(async (opts, cmd) => {
232
+ const ctx = buildCtx(cmd);
233
+ requireAuth(ctx);
234
+ // Autonomous: POST /tx/bridge-deposit builds the USDT-approve + Across bridge
235
+ // calls and executes them via the Biconomy session grant (agent-signed).
236
+ //
237
+ // Two-stage settlement: the bridge tx confirms the Ethereum leg, but Across
238
+ // fills onto Hyperliquid a few MINUTES later. Trusting only the tx status
239
+ // therefore reports "failed/timeout" for a deposit that is actually on its
240
+ // way (the reported bug). So we (1) confirm the tx, then (2) reconcile by
241
+ // polling for the HL balance to actually rise before declaring success.
242
+ const m = await resolveCash(ctx, opts.amount, 6, { usd: !!opts.usd });
243
+ const wait = opts.wait !== false;
244
+ const label = `Deposit ${describeCashResolution(m)} cash → Hyperliquid`;
245
+ // Snapshot HL equity before, so we can detect the balance increase.
246
+ let preBalance = 0;
247
+ if (wait) {
248
+ try {
249
+ preBalance = parseFloat((await ctx.client.hlStatus())?.balance ?? '0') || 0;
250
+ }
251
+ catch { /* non-fatal — proceed without a baseline */ }
252
+ }
253
+ const res = await ctx.client.hlDeposit({ amountUsdt: m.usdString });
254
+ const txHash = res?.txHash;
255
+ const tail = txHash ? ` (tx ${txHash})` : '';
256
+ if (!wait) {
257
+ out(ctx, `Submitted: ${label}${tail}.\nFunds arrive on Hyperliquid a few minutes after the bridge tx confirms — check \`grant hl status\`.`, { submitted: true, confirmed: false, status: res?.status ?? 'submitted', txHash });
258
+ return;
259
+ }
260
+ const txResult = await waitForTx(ctx.client, txHash);
261
+ if (txResult.status === 'failed')
262
+ fail(`${label} failed on-chain${tail}.`, ctx.json);
263
+ // Poll for the funds to actually land on HL (Across fill). 95% tolerance
264
+ // absorbs bridge fees/rounding so a real deposit isn't missed.
265
+ const target = preBalance + Number(m.usdString) * 0.95;
266
+ const landed = await waitForHlBalance(ctx, target, 180_000);
267
+ if (landed) {
268
+ out(ctx, `✓ ${label} — now available on Hyperliquid.`, { success: true, confirmed: true, settledOnHl: true, status: 'success', txHash });
269
+ }
270
+ else if (txResult.status === 'success') {
271
+ out(ctx, `${label}: bridge tx confirmed${tail}; funds are still settling on Hyperliquid (this can take a few minutes). Check \`grant hl status\`.`, { submitted: true, confirmed: true, settledOnHl: false, status: 'success', txHash });
272
+ }
273
+ else {
274
+ out(ctx, `${label}: submitted${tail} but not yet confirmed. Re-check with \`grant tx status${txHash ? ` ${txHash}` : ''}\` and \`grant hl status\`.`, { submitted: true, confirmed: false, settledOnHl: false, status: txResult.status, txHash });
275
+ }
276
+ });
277
+ // Autonomous — the backend signs the sendAsset via the Privy autonomous signer
278
+ // (TEE), so this needs NO browser approval. Funds land as cash (USDT) on Ethereum
279
+ // in the user's own wallet.
280
+ hl.command('withdraw').description('Withdraw cash from Hyperliquid back to your wallet on Ethereum')
281
+ .requiredOption('--amount <amount>', 'cash to withdraw from HL — in your display currency by default; prefix $/₹ to force one, or pass --usd')
282
+ .option('--usd', 'interpret --amount as US dollars, ignoring your display currency')
283
+ .option('--no-wait', 'return immediately without waiting for the bridge to settle')
284
+ .action(async (opts, cmd) => {
285
+ const ctx = buildCtx(cmd);
286
+ requireAuth(ctx);
287
+ const m = await resolveCash(ctx, opts.amount, 6, { usd: !!opts.usd });
288
+ const res = await ctx.client.hlWithdraw({ amountUsdc: m.usdString });
289
+ const requestId = res?.relayRequestId;
290
+ const expected = res?.expectedOutputUsdt;
291
+ const tail = expected ? ` (~${expected} USDT expected)` : '';
292
+ if (opts.wait === false || !requestId) {
293
+ out(ctx, `Withdrawal submitted${tail}.${requestId ? `\nCheck progress with \`grant hl withdraw-status ${requestId}\`.` : ''}`, { submitted: true, requestId, ...res });
294
+ return;
295
+ }
296
+ if (!ctx.json)
297
+ console.log(`\nWithdrawing ${describeCashResolution(m)} from Hyperliquid${tail}. Confirming delivery to your wallet…`);
298
+ await pollWithdrawStatus(ctx, requestId, expected);
299
+ });
300
+ hl.command('withdraw-status <requestId>').description('Check a Hyperliquid withdrawal until it lands on Ethereum')
301
+ .action(async (requestId, _o, cmd) => {
302
+ const ctx = buildCtx(cmd);
303
+ requireAuth(ctx);
304
+ await pollWithdrawStatus(ctx, requestId);
305
+ });
306
+ hl.command('open').description('Open a perpetual position on any market (autonomous)')
307
+ .requiredOption('--side <long|short>', 'position direction')
308
+ .requiredOption('--size <usd>', 'position size in USD')
309
+ .requiredOption('--leverage <n>', 'leverage multiplier')
310
+ .option('--asset <symbol>', 'market to trade — e.g. AAPL, TSLA, BTC, GOLD (default GOLD). Use `hl markets` to find symbols.')
311
+ .action(async (opts, cmd) => {
312
+ const ctx = buildCtx(cmd);
313
+ requireAuth(ctx);
314
+ const side = String(opts.side).toLowerCase();
315
+ if (side !== 'long' && side !== 'short')
316
+ fail('--side must be long or short.', ctx.json);
317
+ const leverage = Number(opts.leverage);
318
+ if (!(leverage >= 1))
319
+ fail('--leverage must be at least 1.', ctx.json);
320
+ // --size is the position notional in USD, entered in the user's currency by
321
+ // default (e.g. an INR user typing "5000" means ₹5000 of exposure).
322
+ const sizeM = await resolveCash(ctx, String(opts.size), 2);
323
+ const sizeUsd = sizeM.usd;
324
+ if (!(sizeUsd > 0))
325
+ fail('--size must be greater than 0.', ctx.json);
326
+ // Pre-submit guard: Hyperliquid rejects orders under $10 notional. Warn the
327
+ // user up-front (in their own currency) instead of making the round-trip and
328
+ // showing a rejection — the backend still re-validates authoritatively.
329
+ const preHint = minOrderHint(sizeUsd, await loadDisplayPrefs(ctx));
330
+ if (preHint)
331
+ fail(preHint, ctx.json);
332
+ const asset = opts.asset ? String(opts.asset) : undefined;
333
+ const market = asset ? asset.toUpperCase() : 'gold';
334
+ try {
335
+ const data = await ctx.client.hlOpen({ side: side, sizeUsd, leverage, asset });
336
+ out(ctx, `Opened ${side} ${describeCashResolution(sizeM)} ${market} @ ${leverage}x. Check \`grant hl positions\`.`, { success: true, market, sizeUsd, ...(data ?? {}) });
337
+ }
338
+ catch (e) {
339
+ // If the backend reports the $10 minimum (e.g. after price rounding),
340
+ // re-render it in the user's currency rather than showing a bare "$10".
341
+ const err = e;
342
+ if (err?.code === 'MIN_ORDER_NOT_MET') {
343
+ const prefs = await loadDisplayPrefs(ctx);
344
+ fail(minOrderHint(0, prefs, err.details?.minOrderUsd ?? HL_MIN_ORDER_USD), ctx.json);
345
+ }
346
+ throw e;
347
+ }
348
+ });
349
+ hl.command('close').description('Close a perpetual position (autonomous) — fully, or part with --percentage')
350
+ .option('--asset <symbol>', 'asset to close', 'XAU')
351
+ .option('--percentage <pct>', 'close only this percent of the position (1–100; default 100 = full)', (v) => parseFloat(v))
352
+ .action(async (opts, cmd) => {
353
+ const ctx = buildCtx(cmd);
354
+ requireAuth(ctx);
355
+ const pct = opts.percentage;
356
+ if (pct !== undefined && !(Number.isFinite(pct) && pct > 0 && pct <= 100))
357
+ fail('--percentage must be a number between 1 and 100', ctx.json);
358
+ // Only send sizePct for a genuine PARTIAL close; omit it for a full close so
359
+ // the backend takes its default full-close path (sizePct 100 === full).
360
+ const partial = pct !== undefined && pct < 100;
361
+ const data = await ctx.client.hlClose({
362
+ asset: String(opts.asset).toUpperCase(),
363
+ ...(partial ? { sizePct: pct } : {}),
364
+ });
365
+ const what = partial ? `${pct}% of your ${opts.asset} position` : `${opts.asset} position`;
366
+ out(ctx, `Closed ${what}.`, { success: true, ...(data ?? {}) });
367
+ });
368
+ hl.command('leverage').description('Set leverage for the perp market')
369
+ .requiredOption('--set <n>', 'leverage multiplier')
370
+ .option('--isolated', 'use isolated margin (default cross)')
371
+ .action(async (opts, cmd) => {
372
+ const ctx = buildCtx(cmd);
373
+ requireAuth(ctx);
374
+ const data = await ctx.client.hlSetLeverage({ leverage: Number(opts.set), isCross: !opts.isolated });
375
+ out(ctx, `Leverage set to ${opts.set}x (${opts.isolated ? 'isolated' : 'cross'}).`, { success: true, ...(data ?? {}) });
376
+ });
377
+ hl.command('tpsl').description('Set take-profit / stop-loss on a position')
378
+ .requiredOption('--asset <symbol>', 'asset', 'XAU')
379
+ .requiredOption('--side <long|short>', 'position direction')
380
+ .requiredOption('--size <n>', 'position size')
381
+ .option('--tp <price>', 'take-profit price')
382
+ .option('--sl <price>', 'stop-loss price')
383
+ .action(async (opts, cmd) => {
384
+ const ctx = buildCtx(cmd);
385
+ requireAuth(ctx);
386
+ const side = String(opts.side).toLowerCase();
387
+ if (side !== 'long' && side !== 'short')
388
+ fail('--side must be long or short.', ctx.json);
389
+ if (!opts.tp && !opts.sl)
390
+ fail('Provide at least one of --tp or --sl.', ctx.json);
391
+ const data = await ctx.client.hlTpsl({
392
+ asset: String(opts.asset).toUpperCase(),
393
+ side: side,
394
+ size: String(opts.size),
395
+ takeProfit: opts.tp ? Number(opts.tp) : undefined,
396
+ stopLoss: opts.sl ? Number(opts.sl) : undefined,
397
+ });
398
+ out(ctx, 'Take-profit / stop-loss set.', { success: true, ...(data ?? {}) });
399
+ });
400
+ hl.command('cancel <orderId>').description('Cancel an open order')
401
+ .option('--asset <symbol>', 'asset', 'XAU')
402
+ .action(async (orderId, opts, cmd) => {
403
+ const ctx = buildCtx(cmd);
404
+ requireAuth(ctx);
405
+ const data = await ctx.client.hlCancelOrder({ oid: Number(orderId), asset: opts.asset?.toUpperCase() });
406
+ out(ctx, `Cancelled order ${orderId}.`, { success: true, ...(data ?? {}) });
407
+ });
408
+ }
@@ -0,0 +1,34 @@
1
+ import { buildCtx, requireAuth, resolver, out } from '../money-helpers.js';
2
+ /**
3
+ * Loan + funding-address commands ported from the standalone Perfolio CLI. The
4
+ * combined `grant portfolio` / `grant balance` view (which folds in predictions
5
+ * + earn) lives separately in commands/portfolio.ts — these are the
6
+ * loan-detail and deposit-address commands that have no combined-view overlap.
7
+ */
8
+ export function registerLoans(program) {
9
+ program.command('loans').description('All open "cash borrowed against X" positions')
10
+ .action(async (_o, cmd) => {
11
+ const ctx = buildCtx(cmd);
12
+ requireAuth(ctx);
13
+ const data = await ctx.client.getPosition('XAUT', 'USDT'); // v1: canonical gold-backed loan
14
+ out(ctx, JSON.stringify(data, null, 2), data);
15
+ });
16
+ program.command('position').description('Detail for one loan')
17
+ .requiredOption('--against <asset>', 'collateral asset (gold, bitcoin, ethereum)')
18
+ .action(async (opts, cmd) => {
19
+ const ctx = buildCtx(cmd);
20
+ requireAuth(ctx);
21
+ const r = await resolver(ctx);
22
+ const collateral = r.resolve(opts.against).symbol;
23
+ const data = await ctx.client.getPosition(collateral, 'USDT');
24
+ out(ctx, JSON.stringify(data, null, 2), data);
25
+ });
26
+ program.command('deposit').description('Show your address for funding the account')
27
+ .action((_o, cmd) => {
28
+ const ctx = buildCtx(cmd);
29
+ const creds = requireAuth(ctx);
30
+ const walletAddress = creds.money?.walletAddress;
31
+ out(ctx, `Send funds to your wallet on Ethereum:\n\n ${walletAddress}\n\n` +
32
+ `Supported: USDT, USDC, ETH and supported collaterals. Only send on Ethereum mainnet.`, { walletAddress, chain: 'ethereum' });
33
+ });
34
+ }
@@ -0,0 +1,76 @@
1
+ import { buildCtx, out, loadDisplayPrefs } from '../money-helpers.js';
2
+ import { formatMoney, GRAMS_PER_TROY_OUNCE } from '../../lib/index.js';
3
+ /** Pull a crypto unit price (USD) by symbol from the /prices/crypto array. */
4
+ function cryptoPrice(crypto, wanted) {
5
+ if (!Array.isArray(crypto))
6
+ return null;
7
+ const hit = crypto.find((e) => {
8
+ const sym = (e?.currency ?? e?.symbol);
9
+ return typeof sym === 'string' && sym.toUpperCase() === wanted;
10
+ });
11
+ const p = typeof hit?.price === 'number' ? hit.price : NaN;
12
+ return Number.isFinite(p) && p > 0 ? p : null;
13
+ }
14
+ /**
15
+ * If a price feed is flagged stale, return a loud inline warning (e.g. " ⚠ stale
16
+ * (16h old)"); '' when fresh/unknown. A quiet `stale:true` boolean was easy to miss
17
+ * — acting on a 16-hour-old gold mark is a real risk, so it must be visible.
18
+ */
19
+ export function staleNote(src) {
20
+ const o = src;
21
+ if (!o || o.stale !== true)
22
+ return '';
23
+ const age = typeof o.ageSeconds === 'number' && o.ageSeconds > 0 ? o.ageSeconds : undefined;
24
+ if (age === undefined)
25
+ return ' ⚠ stale';
26
+ const h = Math.floor(age / 3600);
27
+ const m = Math.round((age % 3600) / 60);
28
+ const ageStr = h > 0 ? `${h}h${m > 0 ? ` ${m}m` : ''}` : `${m}m`;
29
+ return ` ⚠ stale (${ageStr} old — may be out of date)`;
30
+ }
31
+ /** Human-readable price board, localized to the user's currency + gold unit. Exported for tests. */
32
+ export function formatPrices(gold, crypto, prefs) {
33
+ const lines = ['Prices'];
34
+ let anyPrice = false;
35
+ const goldUsdPerOz = typeof gold?.price === 'number' ? gold.price : null;
36
+ if (goldUsdPerOz != null) {
37
+ const perGram = prefs.goldUnit === 'g';
38
+ const unitPrice = perGram ? goldUsdPerOz / GRAMS_PER_TROY_OUNCE : goldUsdPerOz;
39
+ lines.push(` Gold ${formatMoney(unitPrice, prefs)}/${perGram ? 'g' : 'oz'}${staleNote(gold)}`);
40
+ anyPrice = true;
41
+ }
42
+ const btc = cryptoPrice(crypto, 'BTC');
43
+ if (btc != null) {
44
+ lines.push(` Bitcoin ${formatMoney(btc, prefs)}`);
45
+ anyPrice = true;
46
+ }
47
+ const eth = cryptoPrice(crypto, 'ETH');
48
+ if (eth != null) {
49
+ lines.push(` Ethereum ${formatMoney(eth, prefs)}`);
50
+ anyPrice = true;
51
+ }
52
+ if (!anyPrice)
53
+ return 'Prices are unavailable right now — try again in a moment.';
54
+ lines.push(' Cash 1 cash ≈ $1 (a US-dollar stablecoin)');
55
+ return lines.join('\n');
56
+ }
57
+ export function registerMarket(program) {
58
+ program.command('prices').description('Current prices for gold, bitcoin, ethereum, cash')
59
+ .action(async (_o, cmd) => {
60
+ const ctx = buildCtx(cmd);
61
+ const [gold, crypto] = await Promise.all([ctx.client.getGoldPrice(), ctx.client.getCryptoPrices()]);
62
+ if (ctx.json) {
63
+ out(ctx, '', { gold, crypto });
64
+ return;
65
+ }
66
+ const prefs = await loadDisplayPrefs(ctx);
67
+ out(ctx, formatPrices(gold, crypto, prefs), { gold, crypto });
68
+ });
69
+ program.command('rate').description('Borrow rate for a market')
70
+ .option('--against <asset>', 'collateral asset', 'gold')
71
+ .action(async (_opts, cmd) => {
72
+ const ctx = buildCtx(cmd);
73
+ const [rate, stats] = await Promise.all([ctx.client.getBorrowRate(), ctx.client.getMarketStats()]);
74
+ out(ctx, JSON.stringify({ rate, stats }, null, 2), { rate, stats });
75
+ });
76
+ }