@earnforge/cli 0.1.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.
@@ -0,0 +1,1170 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import { MAX_UINT256, buildApprovalTx, checkAllowance, createEarnForge, parseTvl, riskScore } from "@earnforge/sdk";
5
+ import Table from "cli-table3";
6
+ //#region src/helpers.ts
7
+ function fmtPct(n) {
8
+ return `${n.toFixed(2)}%`;
9
+ }
10
+ function fmtUsd(n) {
11
+ if (n >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
12
+ if (n >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
13
+ if (n >= 1e3) return `$${(n / 1e3).toFixed(2)}K`;
14
+ return `$${n.toFixed(2)}`;
15
+ }
16
+ function riskLabel(score) {
17
+ if (score >= 7) return chalk.green(`${score}/10 (low)`);
18
+ if (score >= 4) return chalk.yellow(`${score}/10 (medium)`);
19
+ return chalk.red(`${score}/10 (high)`);
20
+ }
21
+ function vaultTable(vaults) {
22
+ const table = new Table({
23
+ head: [
24
+ chalk.bold("Name"),
25
+ chalk.bold("Chain"),
26
+ chalk.bold("Protocol"),
27
+ chalk.bold("APY"),
28
+ chalk.bold("TVL"),
29
+ chalk.bold("Tags"),
30
+ chalk.bold("Slug")
31
+ ],
32
+ colWidths: [
33
+ 30,
34
+ 8,
35
+ 14,
36
+ 10,
37
+ 12,
38
+ 16,
39
+ 20
40
+ ],
41
+ wordWrap: true
42
+ });
43
+ for (const v of vaults) {
44
+ const tvl = parseTvl(v.analytics.tvl);
45
+ table.push([
46
+ v.name,
47
+ String(v.chainId),
48
+ v.protocol.name,
49
+ chalk.green(fmtPct(v.analytics.apy.total)),
50
+ fmtUsd(tvl.parsed),
51
+ v.tags.join(", "),
52
+ v.slug
53
+ ]);
54
+ }
55
+ return table.toString();
56
+ }
57
+ function vaultDetail(v) {
58
+ const tvl = parseTvl(v.analytics.tvl);
59
+ return [
60
+ chalk.bold.underline(v.name),
61
+ "",
62
+ ` ${chalk.dim("Slug:")} ${v.slug}`,
63
+ ` ${chalk.dim("Chain:")} ${v.chainId} (${v.network})`,
64
+ ` ${chalk.dim("Address:")} ${v.address}`,
65
+ ` ${chalk.dim("Protocol:")} ${v.protocol.name} (${v.protocol.url})`,
66
+ ` ${chalk.dim("Provider:")} ${v.provider}`,
67
+ ` ${chalk.dim("Tags:")} ${v.tags.join(", ") || "(none)"}`,
68
+ "",
69
+ chalk.bold("Analytics"),
70
+ ` ${chalk.dim("APY Total:")} ${chalk.green(fmtPct(v.analytics.apy.total))}`,
71
+ ` ${chalk.dim("APY Base:")} ${fmtPct(v.analytics.apy.base)}`,
72
+ ` ${chalk.dim("APY Reward:")} ${fmtPct(v.analytics.apy.reward)}`,
73
+ ` ${chalk.dim("APY 1d:")} ${v.analytics.apy1d !== null ? fmtPct(v.analytics.apy1d) : "N/A"}`,
74
+ ` ${chalk.dim("APY 7d:")} ${v.analytics.apy7d !== null ? fmtPct(v.analytics.apy7d) : "N/A"}`,
75
+ ` ${chalk.dim("APY 30d:")} ${v.analytics.apy30d !== null ? fmtPct(v.analytics.apy30d) : "N/A"}`,
76
+ ` ${chalk.dim("TVL:")} ${fmtUsd(tvl.parsed)}`,
77
+ ` ${chalk.dim("Updated:")} ${v.analytics.updatedAt}`,
78
+ "",
79
+ chalk.bold("Deposit & Redeem"),
80
+ ` ${chalk.dim("Transactional:")} ${v.isTransactional ? chalk.green("Yes") : chalk.red("No")}`,
81
+ ` ${chalk.dim("Redeemable:")} ${v.isRedeemable ? chalk.green("Yes") : chalk.red("No")}`,
82
+ ` ${chalk.dim("Deposit Packs:")} ${v.depositPacks.map((p) => p.name).join(", ") || "(none)"}`,
83
+ ` ${chalk.dim("Redeem Packs:")} ${v.redeemPacks.map((p) => p.name).join(", ") || "(none)"}`,
84
+ "",
85
+ chalk.bold("Underlying Tokens"),
86
+ ...v.underlyingTokens.map((t) => ` ${t.symbol} (${t.address}) — ${t.decimals} decimals`),
87
+ ...v.underlyingTokens.length === 0 ? [" (none)"] : [],
88
+ "",
89
+ ...v.description ? [
90
+ chalk.bold("Description"),
91
+ ` ${v.description}`,
92
+ ""
93
+ ] : []
94
+ ].join("\n");
95
+ }
96
+ function chainTable(chains) {
97
+ const table = new Table({ head: [
98
+ chalk.bold("Chain ID"),
99
+ chalk.bold("Name"),
100
+ chalk.bold("CAIP")
101
+ ] });
102
+ for (const c of chains) table.push([
103
+ String(c.chainId),
104
+ c.name,
105
+ c.networkCaip
106
+ ]);
107
+ return table.toString();
108
+ }
109
+ function protocolTable(protocols) {
110
+ const table = new Table({ head: [chalk.bold("Name"), chalk.bold("URL")] });
111
+ for (const p of protocols) table.push([p.name, p.url]);
112
+ return table.toString();
113
+ }
114
+ function portfolioTable(positions) {
115
+ const table = new Table({ head: [
116
+ chalk.bold("Chain"),
117
+ chalk.bold("Protocol"),
118
+ chalk.bold("Asset"),
119
+ chalk.bold("Balance (Native)"),
120
+ chalk.bold("Balance (USD)")
121
+ ] });
122
+ for (const p of positions) table.push([
123
+ String(p.chainId),
124
+ p.protocolName,
125
+ `${p.asset.symbol} (${p.asset.name})`,
126
+ p.balanceNative,
127
+ `$${Number(p.balanceUsd).toFixed(2)}`
128
+ ]);
129
+ return table.toString();
130
+ }
131
+ function riskTable(risk) {
132
+ const table = new Table({ head: [chalk.bold("Dimension"), chalk.bold("Score")] });
133
+ table.push(["TVL Magnitude", `${risk.breakdown.tvl}/10`]);
134
+ table.push(["APY Stability", `${risk.breakdown.apyStability}/10`]);
135
+ table.push(["Protocol Maturity", `${risk.breakdown.protocol}/10`]);
136
+ table.push(["Redeemability", `${risk.breakdown.redeemability}/10`]);
137
+ table.push(["Asset Type", `${risk.breakdown.assetType}/10`]);
138
+ table.push([chalk.bold("Composite"), chalk.bold(riskLabel(risk.score))]);
139
+ return table.toString();
140
+ }
141
+ function suggestTable(allocations) {
142
+ const table = new Table({ head: [
143
+ chalk.bold("Vault"),
144
+ chalk.bold("Chain"),
145
+ chalk.bold("Protocol"),
146
+ chalk.bold("APY"),
147
+ chalk.bold("Risk"),
148
+ chalk.bold("Allocation"),
149
+ chalk.bold("Amount")
150
+ ] });
151
+ for (const a of allocations) table.push([
152
+ a.vault.name,
153
+ String(a.vault.chainId),
154
+ a.vault.protocol.name,
155
+ chalk.green(fmtPct(a.apy)),
156
+ riskLabel(a.risk.score),
157
+ `${a.percentage.toFixed(1)}%`,
158
+ fmtUsd(a.amount)
159
+ ]);
160
+ return table.toString();
161
+ }
162
+ function apyHistoryTable(history) {
163
+ const table = new Table({ head: [
164
+ chalk.bold("Date"),
165
+ chalk.bold("APY"),
166
+ chalk.bold("TVL")
167
+ ] });
168
+ for (const d of history) {
169
+ const date = d.timestamp.split("T")[0] ?? d.timestamp;
170
+ table.push([
171
+ date,
172
+ fmtPct(d.apy),
173
+ fmtUsd(d.tvlUsd)
174
+ ]);
175
+ }
176
+ return table.toString();
177
+ }
178
+ function preflightTable(report) {
179
+ const lines = [];
180
+ const status = report.ok ? chalk.green("PASS") : chalk.red("FAIL");
181
+ lines.push(chalk.bold(`Preflight — ${report.vault.name} ${status}`));
182
+ lines.push("");
183
+ if (report.issues.length === 0) lines.push(chalk.green(" All checks passed. Ready to deposit."));
184
+ else for (const issue of report.issues) {
185
+ const icon = issue.severity === "error" ? chalk.red("ERROR") : chalk.yellow("WARN");
186
+ lines.push(` ${icon} [${issue.code}] ${issue.message}`);
187
+ }
188
+ return lines.join("\n");
189
+ }
190
+ function compareTable(vaults, risks) {
191
+ const fields = [
192
+ {
193
+ label: "Slug",
194
+ value: (v) => v.slug
195
+ },
196
+ {
197
+ label: "Chain",
198
+ value: (v) => `${v.network} (${v.chainId})`
199
+ },
200
+ {
201
+ label: "Protocol",
202
+ value: (v) => v.protocol.name
203
+ },
204
+ {
205
+ label: "APY Total",
206
+ value: (v) => chalk.green(fmtPct(v.analytics.apy.total))
207
+ },
208
+ {
209
+ label: "APY Base",
210
+ value: (v) => fmtPct(v.analytics.apy.base)
211
+ },
212
+ {
213
+ label: "APY Reward",
214
+ value: (v) => fmtPct(v.analytics.apy.reward)
215
+ },
216
+ {
217
+ label: "APY 7d",
218
+ value: (v) => v.analytics.apy7d !== null ? fmtPct(v.analytics.apy7d) : "N/A"
219
+ },
220
+ {
221
+ label: "APY 30d",
222
+ value: (v) => v.analytics.apy30d !== null ? fmtPct(v.analytics.apy30d) : "N/A"
223
+ },
224
+ {
225
+ label: "TVL",
226
+ value: (v) => fmtUsd(parseTvl(v.analytics.tvl).parsed)
227
+ },
228
+ {
229
+ label: "Risk",
230
+ value: (_, r) => riskLabel(r.score)
231
+ },
232
+ {
233
+ label: "Tags",
234
+ value: (v) => v.tags.join(", ") || "(none)"
235
+ },
236
+ {
237
+ label: "Transactional",
238
+ value: (v) => v.isTransactional ? chalk.green("Yes") : chalk.red("No")
239
+ },
240
+ {
241
+ label: "Redeemable",
242
+ value: (v) => v.isRedeemable ? chalk.green("Yes") : chalk.red("No")
243
+ },
244
+ {
245
+ label: "Underlying",
246
+ value: (v) => v.underlyingTokens.map((t) => t.symbol).join(", ") || "(none)"
247
+ }
248
+ ];
249
+ const table = new Table({
250
+ head: [chalk.bold(""), ...vaults.map((v) => chalk.bold(v.name))],
251
+ wordWrap: true
252
+ });
253
+ for (const field of fields) table.push([chalk.dim(field.label), ...vaults.map((v, i) => field.value(v, risks[i]))]);
254
+ return table.toString();
255
+ }
256
+ function outputResult(data, json, humanFn) {
257
+ if (json) console.log(JSON.stringify(data, null, 2));
258
+ else console.log(humanFn());
259
+ }
260
+ //#endregion
261
+ //#region src/doctor.ts
262
+ /**
263
+ * Run all 18 pitfall checks on a vault.
264
+ * Each check is based on a real pitfall from the LI.FI Earn API.
265
+ */
266
+ function runDoctorChecks(vault, opts) {
267
+ const checks = [];
268
+ checks.push({
269
+ id: 1,
270
+ pitfall: "Base URL split",
271
+ description: "Earn Data uses earn.li.fi, Composer uses li.quest",
272
+ passed: true,
273
+ detail: "SDK uses correct base URLs (earn.li.fi / li.quest)"
274
+ });
275
+ checks.push({
276
+ id: 2,
277
+ pitfall: "No auth for Earn Data",
278
+ description: "Earn Data API (earn.li.fi) requires no authentication",
279
+ passed: true,
280
+ detail: "SDK sends no auth headers to earn.li.fi"
281
+ });
282
+ checks.push({
283
+ id: 3,
284
+ pitfall: "Composer API key",
285
+ description: "Composer (li.quest) requires x-lifi-api-key header",
286
+ passed: opts.hasApiKey,
287
+ detail: opts.hasApiKey ? "LIFI_API_KEY is set" : "LIFI_API_KEY is NOT set — quote/deposit commands will fail"
288
+ });
289
+ checks.push({
290
+ id: 4,
291
+ pitfall: "GET for Composer",
292
+ description: "Composer quote endpoint uses GET, not POST",
293
+ passed: true,
294
+ detail: "SDK uses GET for /v1/quote"
295
+ });
296
+ checks.push({
297
+ id: 5,
298
+ pitfall: "toToken = vault.address",
299
+ description: "Deposit toToken must be vault.address, not underlying token",
300
+ passed: true,
301
+ detail: `Vault address: ${vault.address}`
302
+ });
303
+ checks.push({
304
+ id: 6,
305
+ pitfall: "Pagination via nextCursor",
306
+ description: "Vault list is paginated — must follow nextCursor",
307
+ passed: true,
308
+ detail: "SDK auto-paginates via listAllVaults()"
309
+ });
310
+ const apyTotal = vault.analytics.apy.total;
311
+ const apyIsReasonable = apyTotal >= 0 && apyTotal < 500;
312
+ checks.push({
313
+ id: 7,
314
+ pitfall: "APY value is reasonable",
315
+ description: "apy.total is already a percentage (3.84 = 3.84%), not a fraction",
316
+ passed: apyIsReasonable,
317
+ detail: `apy.total = ${apyTotal.toFixed(2)}%`
318
+ });
319
+ const tvl = parseTvl(vault.analytics.tvl);
320
+ const tvlIsString = typeof vault.analytics.tvl.usd === "string";
321
+ checks.push({
322
+ id: 8,
323
+ pitfall: "TVL.usd is a string",
324
+ description: "tvl.usd comes as a string, must parse to number",
325
+ passed: tvlIsString,
326
+ detail: `tvl.usd type=${typeof vault.analytics.tvl.usd}, parsed=${tvl.parsed}`
327
+ });
328
+ const hasDecimals = vault.underlyingTokens.length > 0;
329
+ const decimals = vault.underlyingTokens[0]?.decimals;
330
+ checks.push({
331
+ id: 9,
332
+ pitfall: "Token decimals",
333
+ description: "Use underlyingTokens[0].decimals for amount conversion (6 for USDC, 18 for ETH)",
334
+ passed: hasDecimals,
335
+ detail: hasDecimals ? `Decimals: ${decimals} (${vault.underlyingTokens[0]?.symbol})` : "No underlyingTokens — decimals unknown, must specify fromToken manually"
336
+ });
337
+ const chainIdIsNumber = typeof vault.chainId === "number";
338
+ checks.push({
339
+ id: 10,
340
+ pitfall: "chainId is a number",
341
+ description: "Use numeric chainId (8453), not chain name (\"Base\")",
342
+ passed: chainIdIsNumber,
343
+ detail: `chainId = ${vault.chainId} (type: ${typeof vault.chainId})`
344
+ });
345
+ checks.push({
346
+ id: 11,
347
+ pitfall: "Gas token needed",
348
+ description: "Wallet must have native gas on the vault chain for tx execution",
349
+ passed: true,
350
+ detail: `Vault is on chain ${vault.chainId} — ensure wallet has native gas`
351
+ });
352
+ checks.push({
353
+ id: 12,
354
+ pitfall: "Chain mismatch",
355
+ description: "Wallet chain must match vault chain (or use cross-chain route)",
356
+ passed: true,
357
+ detail: `Vault on chain ${vault.chainId} — ensure wallet is on same chain or use bridge`
358
+ });
359
+ checks.push({
360
+ id: 13,
361
+ pitfall: "isTransactional",
362
+ description: "Vault must be isTransactional=true to deposit via Composer",
363
+ passed: vault.isTransactional,
364
+ detail: vault.isTransactional ? "Vault is transactional — deposits supported" : "Vault is NOT transactional — cannot deposit via API"
365
+ });
366
+ checks.push({
367
+ id: 14,
368
+ pitfall: "isRedeemable",
369
+ description: "If isRedeemable=false, you may not be able to withdraw",
370
+ passed: vault.isRedeemable,
371
+ detail: vault.isRedeemable ? "Vault is redeemable" : "Vault is NOT redeemable — funds may be locked"
372
+ });
373
+ const hasUnderlyingTokens = vault.underlyingTokens.length > 0;
374
+ checks.push({
375
+ id: 15,
376
+ pitfall: "underlyingTokens[]",
377
+ description: "Some vaults have empty underlyingTokens — must specify fromToken manually",
378
+ passed: hasUnderlyingTokens,
379
+ detail: hasUnderlyingTokens ? `Underlying: ${vault.underlyingTokens.map((t) => t.symbol).join(", ")}` : "underlyingTokens is EMPTY — you must pass fromToken explicitly"
380
+ });
381
+ const hasDescription = vault.description !== void 0 && vault.description !== "";
382
+ checks.push({
383
+ id: 16,
384
+ pitfall: "description optional",
385
+ description: "~14% of vaults lack a description field — do not assume it exists",
386
+ passed: true,
387
+ detail: hasDescription ? `Description present: "${vault.description.slice(0, 60)}..."` : "No description (this is expected for some vaults)"
388
+ });
389
+ checks.push({
390
+ id: 17,
391
+ pitfall: "apy.reward nullable",
392
+ description: "apy.reward is null for some protocols (Morpho) — normalize to 0",
393
+ passed: typeof vault.analytics.apy.reward === "number",
394
+ detail: `apy.reward = ${vault.analytics.apy.reward} (type: ${typeof vault.analytics.apy.reward})`
395
+ });
396
+ const apyFields = {
397
+ apy1d: vault.analytics.apy1d,
398
+ apy7d: vault.analytics.apy7d,
399
+ apy30d: vault.analytics.apy30d
400
+ };
401
+ const nullApyFields = Object.entries(apyFields).filter(([, v]) => v === null).map(([k]) => k);
402
+ checks.push({
403
+ id: 18,
404
+ pitfall: "Historical APY nullable",
405
+ description: "apy1d, apy7d, apy30d can all be null — use fallback chain",
406
+ passed: true,
407
+ detail: nullApyFields.length > 0 ? `Null fields: ${nullApyFields.join(", ")} — SDK uses fallback chain` : "All historical APY fields present"
408
+ });
409
+ const passed = checks.filter((c) => c.passed).length;
410
+ const failed = checks.filter((c) => !c.passed).length;
411
+ const risk = riskScore(vault);
412
+ return {
413
+ checks,
414
+ passed,
415
+ failed,
416
+ total: checks.length,
417
+ riskScore: {
418
+ score: risk.score,
419
+ label: risk.label
420
+ }
421
+ };
422
+ }
423
+ function formatDoctorReport(report, vaultName) {
424
+ const lines = [];
425
+ lines.push(chalk.bold.underline(`EarnForge Doctor${vaultName ? ` — ${vaultName}` : ""}`));
426
+ lines.push("");
427
+ for (const check of report.checks) {
428
+ const icon = check.passed ? chalk.green("OK") : chalk.red("FAIL");
429
+ const num = `#${String(check.id).padStart(2, "0")}`;
430
+ lines.push(` ${icon} ${chalk.dim(num)} ${chalk.bold(check.pitfall)}`);
431
+ lines.push(` ${chalk.dim(check.description)}`);
432
+ lines.push(` ${check.detail}`);
433
+ lines.push("");
434
+ }
435
+ lines.push(chalk.bold("Summary"));
436
+ lines.push(` ${chalk.green(`${report.passed} passed`)} ${report.failed > 0 ? chalk.red(`${report.failed} failed`) : chalk.dim("0 failed")} ${chalk.dim(`${report.total} total`)}`);
437
+ if (report.riskScore) {
438
+ lines.push("");
439
+ lines.push(chalk.bold("Risk Score"));
440
+ lines.push(` ${riskLabel(report.riskScore.score)}`);
441
+ }
442
+ return lines.join("\n");
443
+ }
444
+ /**
445
+ * Run environment-only doctor checks (no vault needed).
446
+ */
447
+ function runEnvChecks() {
448
+ const checks = [];
449
+ const hasApiKey = !!process.env["LIFI_API_KEY"];
450
+ checks.push({
451
+ id: 1,
452
+ pitfall: "LIFI_API_KEY",
453
+ description: "Composer API key for quote/deposit operations",
454
+ passed: hasApiKey,
455
+ detail: hasApiKey ? "LIFI_API_KEY is set" : "LIFI_API_KEY is NOT set"
456
+ });
457
+ const nodeVersion = process.version;
458
+ const majorVersion = parseInt(nodeVersion.slice(1), 10);
459
+ checks.push({
460
+ id: 2,
461
+ pitfall: "Node.js version",
462
+ description: "Node.js 18+ required for native fetch",
463
+ passed: majorVersion >= 18,
464
+ detail: `Node.js ${nodeVersion} (major: ${majorVersion})`
465
+ });
466
+ checks.push({
467
+ id: 3,
468
+ pitfall: "Earn Data API",
469
+ description: "earn.li.fi is the read-only API (no auth needed)",
470
+ passed: true,
471
+ detail: "earn.li.fi — public, no API key required"
472
+ });
473
+ checks.push({
474
+ id: 4,
475
+ pitfall: "Composer API",
476
+ description: "li.quest is the Composer API (requires API key)",
477
+ passed: hasApiKey,
478
+ detail: hasApiKey ? "li.quest will use LIFI_API_KEY for x-lifi-api-key header" : "li.quest requires LIFI_API_KEY — set it to use quote/deposit"
479
+ });
480
+ return {
481
+ checks,
482
+ passed: checks.filter((c) => c.passed).length,
483
+ failed: checks.filter((c) => !c.passed).length,
484
+ total: checks.length
485
+ };
486
+ }
487
+ function formatEnvReport(report) {
488
+ const lines = [];
489
+ lines.push(chalk.bold.underline("EarnForge Doctor — Environment"));
490
+ lines.push("");
491
+ for (const check of report.checks) {
492
+ const icon = check.passed ? chalk.green("OK") : chalk.red("FAIL");
493
+ lines.push(` ${icon} ${chalk.bold(check.pitfall)}`);
494
+ lines.push(` ${chalk.dim(check.description)}`);
495
+ lines.push(` ${check.detail}`);
496
+ lines.push("");
497
+ }
498
+ lines.push(chalk.bold("Summary"));
499
+ lines.push(` ${chalk.green(`${report.passed} passed`)} ${report.failed > 0 ? chalk.red(`${report.failed} failed`) : chalk.dim("0 failed")} ${chalk.dim(`${report.total} total`)}`);
500
+ return lines.join("\n");
501
+ }
502
+ //#endregion
503
+ //#region src/index.ts
504
+ let _forge;
505
+ function getForge() {
506
+ if (!_forge) _forge = createEarnForge({ composerApiKey: process.env["LIFI_API_KEY"] });
507
+ return _forge;
508
+ }
509
+ /** Override forge instance (for testing) */
510
+ function setForge(forge) {
511
+ _forge = forge;
512
+ }
513
+ /** Reset forge instance */
514
+ function resetForge() {
515
+ _forge = void 0;
516
+ }
517
+ const VALID_STRATEGIES = [
518
+ "conservative",
519
+ "max-apy",
520
+ "diversified",
521
+ "risk-adjusted"
522
+ ];
523
+ function validateStrategy(s) {
524
+ if (!VALID_STRATEGIES.includes(s)) throw new Error(`Invalid strategy: ${s}. Valid: ${VALID_STRATEGIES.join(", ")}`);
525
+ return s;
526
+ }
527
+ const program = new Command();
528
+ program.name("earnforge").version("0.1.0").description("EarnForge CLI — Developer toolkit for the LI.FI Earn API");
529
+ program.command("list").description("List vaults with filters").option("--chain <id>", "Filter by chain ID", parseInt).option("--asset <sym>", "Filter by asset symbol").option("--min-apy <n>", "Minimum APY (e.g. 5 = 5%)", parseFloat).option("--min-tvl <n>", "Minimum TVL in USD", parseFloat).option("--sort <field>", "Sort by apy or tvl", "apy").option("--limit <n>", "Max results", parseInt).option("--strategy <preset>", "Strategy preset (conservative, max-apy, diversified, risk-adjusted)").option("--json", "Output as JSON", false).action(async (opts) => {
530
+ const spinner = ora("Fetching vaults...").start();
531
+ try {
532
+ const forge = getForge();
533
+ const limit = opts.limit ?? 20;
534
+ const strategy = opts.strategy ? validateStrategy(opts.strategy) : void 0;
535
+ const vaults = [];
536
+ for await (const v of forge.vaults.listAll({
537
+ chainId: opts.chain,
538
+ asset: opts.asset,
539
+ minTvl: opts.minTvl,
540
+ strategy
541
+ })) {
542
+ if (opts.minApy !== void 0 && v.analytics.apy.total < opts.minApy) continue;
543
+ if (opts.minTvl !== void 0 && parseTvl(v.analytics.tvl).parsed < opts.minTvl) continue;
544
+ vaults.push(v);
545
+ if (vaults.length >= limit * 3) break;
546
+ }
547
+ if (opts.sort === "tvl") vaults.sort((a, b) => parseTvl(b.analytics.tvl).parsed - parseTvl(a.analytics.tvl).parsed);
548
+ else vaults.sort((a, b) => b.analytics.apy.total - a.analytics.apy.total);
549
+ const result = vaults.slice(0, limit);
550
+ spinner.stop();
551
+ outputResult(result.map((v) => ({
552
+ slug: v.slug,
553
+ name: v.name,
554
+ chainId: v.chainId,
555
+ protocol: v.protocol.name,
556
+ apy: v.analytics.apy.total,
557
+ tvlUsd: parseTvl(v.analytics.tvl).parsed,
558
+ tags: v.tags,
559
+ isTransactional: v.isTransactional
560
+ })), opts.json, () => {
561
+ if (result.length === 0) return chalk.yellow("No vaults found matching your filters.");
562
+ return `${vaultTable(result)}\n\n${chalk.dim(`Showing ${result.length} vault${result.length !== 1 ? "s" : ""}`)}`;
563
+ });
564
+ } catch (err) {
565
+ spinner.fail("Failed to fetch vaults");
566
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
567
+ process.exitCode = 1;
568
+ }
569
+ });
570
+ program.command("top").description("Show top vaults by APY for a given asset").requiredOption("--asset <sym>", "Asset symbol (e.g. USDC, WETH)").option("--chain <id>", "Filter by chain ID", parseInt).option("--limit <n>", "Max results", parseInt).option("--json", "Output as JSON", false).action(async (opts) => {
571
+ const spinner = ora("Fetching top vaults...").start();
572
+ try {
573
+ const result = await getForge().vaults.top({
574
+ asset: opts.asset,
575
+ chainId: opts.chain,
576
+ limit: opts.limit ?? 10
577
+ });
578
+ spinner.stop();
579
+ outputResult(result.map((v) => ({
580
+ slug: v.slug,
581
+ name: v.name,
582
+ chainId: v.chainId,
583
+ protocol: v.protocol.name,
584
+ apy: v.analytics.apy.total,
585
+ tvlUsd: parseTvl(v.analytics.tvl).parsed,
586
+ tags: v.tags
587
+ })), opts.json, () => {
588
+ if (result.length === 0) return chalk.yellow(`No vaults found for asset: ${opts.asset}`);
589
+ return `${chalk.bold(`Top ${opts.asset} vaults`)}\n\n${vaultTable(result)}`;
590
+ });
591
+ } catch (err) {
592
+ spinner.fail("Failed to fetch top vaults");
593
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
594
+ process.exitCode = 1;
595
+ }
596
+ });
597
+ program.command("vault").description("Get detailed vault info by slug").argument("<slug>", "Vault slug (e.g. 8453-0xbeef...)").option("--json", "Output as JSON", false).action(async (slug, opts) => {
598
+ const spinner = ora("Fetching vault...").start();
599
+ try {
600
+ const vault = await getForge().vaults.get(slug);
601
+ spinner.stop();
602
+ outputResult(vault, opts.json, () => vaultDetail(vault));
603
+ } catch (err) {
604
+ spinner.fail("Failed to fetch vault");
605
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
606
+ process.exitCode = 1;
607
+ }
608
+ });
609
+ program.command("compare").description("Side-by-side comparison of 2 or more vaults").argument("<slugs...>", "Vault slugs to compare (space-separated)").option("--json", "Output as JSON", false).action(async (slugs, opts) => {
610
+ if (slugs.length < 2) {
611
+ console.error(chalk.red("Provide at least 2 vault slugs to compare."));
612
+ process.exitCode = 1;
613
+ return;
614
+ }
615
+ const spinner = ora(`Fetching ${slugs.length} vaults...`).start();
616
+ try {
617
+ const forge = getForge();
618
+ const vaults = await Promise.all(slugs.map((s) => forge.vaults.get(s)));
619
+ const risks = vaults.map((v) => forge.riskScore(v));
620
+ spinner.stop();
621
+ outputResult(vaults.map((v, i) => ({
622
+ slug: v.slug,
623
+ name: v.name,
624
+ chainId: v.chainId,
625
+ network: v.network,
626
+ protocol: v.protocol.name,
627
+ apy: v.analytics.apy,
628
+ apy7d: v.analytics.apy7d,
629
+ apy30d: v.analytics.apy30d,
630
+ tvlUsd: parseTvl(v.analytics.tvl).parsed,
631
+ risk: risks[i],
632
+ tags: v.tags,
633
+ isTransactional: v.isTransactional,
634
+ isRedeemable: v.isRedeemable,
635
+ underlyingTokens: v.underlyingTokens.map((t) => t.symbol)
636
+ })), opts.json, () => {
637
+ return `${chalk.bold(`Vault Comparison (${vaults.length} vaults)`)}\n\n${compareTable(vaults, risks)}`;
638
+ });
639
+ } catch (err) {
640
+ spinner.fail("Failed to compare vaults");
641
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
642
+ process.exitCode = 1;
643
+ }
644
+ });
645
+ program.command("portfolio").description("View portfolio positions for a wallet").argument("<wallet>", "Wallet address").option("--json", "Output as JSON", false).action(async (wallet, opts) => {
646
+ const spinner = ora("Fetching portfolio...").start();
647
+ try {
648
+ const portfolio = await getForge().portfolio.get(wallet);
649
+ spinner.stop();
650
+ outputResult(portfolio, opts.json, () => {
651
+ if (portfolio.positions.length === 0) return chalk.yellow("No positions found.");
652
+ const totalUsd = portfolio.positions.reduce((sum, p) => sum + Number(p.balanceUsd), 0);
653
+ return `${chalk.bold("Portfolio")}\n\n${portfolioTable(portfolio.positions)}\n\n${chalk.dim(`Total: ${fmtUsd(totalUsd)}`)}`;
654
+ });
655
+ } catch (err) {
656
+ spinner.fail("Failed to fetch portfolio");
657
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
658
+ process.exitCode = 1;
659
+ }
660
+ });
661
+ program.command("quote").description("Build a deposit quote").requiredOption("--vault <slug>", "Vault slug").requiredOption("--amount <human>", "Human-readable deposit amount").requiredOption("--wallet <addr>", "Wallet address").option("--from-token <addr>", "Override from token address").option("--from-chain <id>", "Override source chain", parseInt).option("--optimize-gas", "Compare routes from multiple chains", false).option("--json", "Output as JSON", false).action(async (opts) => {
662
+ const spinner = ora("Building quote...").start();
663
+ try {
664
+ const forge = getForge();
665
+ const vault = await forge.vaults.get(opts.vault);
666
+ if (opts.optimizeGas) {
667
+ spinner.text = "Comparing cross-chain routes...";
668
+ const routes = await forge.optimizeGasRoutes(vault, {
669
+ fromAmount: opts.amount,
670
+ wallet: opts.wallet,
671
+ fromToken: opts.fromToken,
672
+ fromChains: opts.fromChain ? [opts.fromChain, vault.chainId] : void 0
673
+ });
674
+ spinner.stop();
675
+ outputResult(routes.map((r) => ({
676
+ fromChain: r.fromChain,
677
+ fromChainName: r.fromChainName,
678
+ totalCostUsd: r.totalCostUsd,
679
+ gasCostUsd: r.gasCostUsd,
680
+ feeCostUsd: r.feeCostUsd,
681
+ executionDuration: r.executionDuration
682
+ })), opts.json, () => {
683
+ if (routes.length === 0) return chalk.yellow("No routes found.");
684
+ const lines = [chalk.bold(`Gas-optimized routes for ${vault.name}`), ""];
685
+ for (const r of routes) {
686
+ const best = r === routes[0] ? chalk.green(" << cheapest") : "";
687
+ lines.push(` ${r.fromChainName} (${r.fromChain}): gas=${fmtUsd(r.gasCostUsd)} fee=${fmtUsd(r.feeCostUsd)} total=${fmtUsd(r.totalCostUsd)} time=${r.executionDuration}s${best}`);
688
+ }
689
+ return lines.join("\n");
690
+ });
691
+ } else {
692
+ const result = await forge.buildDepositQuote(vault, {
693
+ fromAmount: opts.amount,
694
+ wallet: opts.wallet,
695
+ fromToken: opts.fromToken,
696
+ fromChain: opts.fromChain
697
+ });
698
+ spinner.stop();
699
+ const approvalAddress = result.quote.estimate.approvalAddress;
700
+ outputResult({
701
+ vault: vault.slug,
702
+ humanAmount: result.humanAmount,
703
+ rawAmount: result.rawAmount,
704
+ decimals: result.decimals,
705
+ fromToken: result.quote.action.fromToken.symbol,
706
+ toToken: result.quote.action.toToken.symbol,
707
+ estimatedOutput: result.quote.estimate.toAmount,
708
+ gasCosts: result.quote.estimate.gasCosts,
709
+ feeCosts: result.quote.estimate.feeCosts,
710
+ executionDuration: result.quote.estimate.executionDuration,
711
+ approvalAddress
712
+ }, opts.json, () => {
713
+ const lines = [
714
+ chalk.bold(`Deposit Quote — ${vault.name}`),
715
+ "",
716
+ ` ${chalk.dim("Amount:")} ${result.humanAmount} (${result.rawAmount} raw, ${result.decimals} decimals)`,
717
+ ` ${chalk.dim("From:")} ${result.quote.action.fromToken.symbol} on chain ${result.quote.action.fromChainId}`,
718
+ ` ${chalk.dim("To:")} ${result.quote.action.toToken.symbol} on chain ${result.quote.action.toChainId}`,
719
+ ` ${chalk.dim("Est. Output:")} ${result.quote.estimate.toAmount}`,
720
+ ` ${chalk.dim("Duration:")} ${result.quote.estimate.executionDuration}s`
721
+ ];
722
+ const gasCosts = result.quote.estimate.gasCosts ?? [];
723
+ const feeCosts = result.quote.estimate.feeCosts ?? [];
724
+ if (gasCosts.length > 0) {
725
+ const totalGas = gasCosts.reduce((s, g) => s + Number(g.amountUSD), 0);
726
+ lines.push(` ${chalk.dim("Gas Cost:")} ${fmtUsd(totalGas)}`);
727
+ }
728
+ if (feeCosts.length > 0) {
729
+ const totalFee = feeCosts.reduce((s, f) => s + Number(f.amountUSD), 0);
730
+ lines.push(` ${chalk.dim("Fee Cost:")} ${fmtUsd(totalFee)}`);
731
+ }
732
+ if (approvalAddress) {
733
+ lines.push("");
734
+ lines.push(chalk.yellow(` Approval needed: approve ${result.quote.action.fromToken.symbol} for spender ${approvalAddress}`));
735
+ lines.push(chalk.dim(` Run: earnforge allowance --token ${result.quote.action.fromToken.address} --owner ${opts.wallet} --spender ${approvalAddress} --amount ${result.rawAmount} --chain ${result.quote.action.fromChainId}`));
736
+ }
737
+ lines.push("");
738
+ lines.push(chalk.dim("Transaction ready to sign via quote.transactionRequest"));
739
+ return lines.join("\n");
740
+ });
741
+ }
742
+ } catch (err) {
743
+ spinner.fail("Failed to build quote");
744
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
745
+ process.exitCode = 1;
746
+ }
747
+ });
748
+ program.command("withdraw").description("Build a withdrawal/redeem quote").requiredOption("--vault <slug>", "Vault slug").requiredOption("--amount <human>", "Amount of vault shares to redeem").requiredOption("--wallet <addr>", "Wallet address").option("--to-token <addr>", "Override destination token address").option("--to-chain <id>", "Override destination chain", parseInt).option("--slippage <n>", "Slippage tolerance (0.03 = 3%)", parseFloat).option("--json", "Output as JSON", false).action(async (opts) => {
749
+ const spinner = ora("Building redeem quote...").start();
750
+ try {
751
+ const forge = getForge();
752
+ const vault = await forge.vaults.get(opts.vault);
753
+ const result = await forge.buildRedeemQuote(vault, {
754
+ fromAmount: opts.amount,
755
+ wallet: opts.wallet,
756
+ toToken: opts.toToken,
757
+ toChain: opts.toChain,
758
+ slippage: opts.slippage
759
+ });
760
+ spinner.stop();
761
+ outputResult({
762
+ vault: vault.slug,
763
+ humanAmount: result.humanAmount,
764
+ rawAmount: result.rawAmount,
765
+ fromToken: result.quote.action.fromToken.symbol,
766
+ toToken: result.quote.action.toToken.symbol,
767
+ estimatedOutput: result.quote.estimate.toAmount,
768
+ gasCosts: result.quote.estimate.gasCosts,
769
+ feeCosts: result.quote.estimate.feeCosts,
770
+ executionDuration: result.quote.estimate.executionDuration
771
+ }, opts.json, () => {
772
+ const lines = [
773
+ chalk.bold(`Withdraw Quote — ${vault.name}`),
774
+ "",
775
+ ` ${chalk.dim("Amount:")} ${result.humanAmount} vault shares (${result.rawAmount} raw)`,
776
+ ` ${chalk.dim("From:")} ${result.quote.action.fromToken.symbol} (vault token) on chain ${result.quote.action.fromChainId}`,
777
+ ` ${chalk.dim("To:")} ${result.quote.action.toToken.symbol} on chain ${result.quote.action.toChainId}`,
778
+ ` ${chalk.dim("Est. Output:")} ${result.quote.estimate.toAmount}`,
779
+ ` ${chalk.dim("Duration:")} ${result.quote.estimate.executionDuration}s`
780
+ ];
781
+ const gasCosts = result.quote.estimate.gasCosts ?? [];
782
+ const feeCosts = result.quote.estimate.feeCosts ?? [];
783
+ if (gasCosts.length > 0) {
784
+ const totalGas = gasCosts.reduce((s, g) => s + Number(g.amountUSD), 0);
785
+ lines.push(` ${chalk.dim("Gas Cost:")} ${fmtUsd(totalGas)}`);
786
+ }
787
+ if (feeCosts.length > 0) {
788
+ const totalFee = feeCosts.reduce((s, f) => s + Number(f.amountUSD), 0);
789
+ lines.push(` ${chalk.dim("Fee Cost:")} ${fmtUsd(totalFee)}`);
790
+ }
791
+ lines.push("");
792
+ lines.push(chalk.dim("Transaction ready to sign via quote.transactionRequest"));
793
+ return lines.join("\n");
794
+ });
795
+ } catch (err) {
796
+ spinner.fail("Failed to build redeem quote");
797
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
798
+ process.exitCode = 1;
799
+ }
800
+ });
801
+ program.command("allowance").description("Check ERC-20 token allowance for a spender").requiredOption("--token <addr>", "ERC-20 token address").requiredOption("--owner <addr>", "Token owner (wallet) address").requiredOption("--spender <addr>", "Spender address (from quote.estimate.approvalAddress)").requiredOption("--amount <raw>", "Required amount in smallest unit").requiredOption("--chain <id>", "Chain ID", parseInt).option("--rpc <url>", "Custom RPC URL").option("--json", "Output as JSON", false).action(async (opts) => {
802
+ const spinner = ora("Checking allowance...").start();
803
+ try {
804
+ const result = await checkAllowance(opts.rpc ?? `https://rpc.li.fi/v1/chain/${opts.chain}`, opts.token, opts.owner, opts.spender, BigInt(opts.amount));
805
+ spinner.stop();
806
+ outputResult({
807
+ token: opts.token,
808
+ owner: opts.owner,
809
+ spender: opts.spender,
810
+ allowance: result.allowance.toString(),
811
+ requiredAmount: result.requiredAmount.toString(),
812
+ sufficient: result.sufficient
813
+ }, opts.json, () => {
814
+ const status = result.sufficient ? chalk.green("SUFFICIENT — no approval needed") : chalk.red("INSUFFICIENT — approval required");
815
+ return [
816
+ chalk.bold("Allowance Check"),
817
+ "",
818
+ ` ${chalk.dim("Token:")} ${opts.token}`,
819
+ ` ${chalk.dim("Owner:")} ${opts.owner}`,
820
+ ` ${chalk.dim("Spender:")} ${opts.spender}`,
821
+ ` ${chalk.dim("Allowance:")} ${result.allowance.toString()}`,
822
+ ` ${chalk.dim("Required:")} ${result.requiredAmount.toString()}`,
823
+ ` ${chalk.dim("Status:")} ${status}`,
824
+ "",
825
+ ...result.sufficient ? [] : [chalk.dim("To approve, run:"), chalk.dim(` earnforge approve --token ${opts.token} --spender ${opts.spender} --chain ${opts.chain}`)]
826
+ ].join("\n");
827
+ });
828
+ } catch (err) {
829
+ spinner.fail("Failed to check allowance");
830
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
831
+ process.exitCode = 1;
832
+ }
833
+ });
834
+ program.command("approve").description("Build an ERC-20 approval transaction").requiredOption("--token <addr>", "ERC-20 token address").requiredOption("--spender <addr>", "Spender address (from quote.estimate.approvalAddress)").requiredOption("--chain <id>", "Chain ID", parseInt).option("--amount <raw>", "Amount to approve (default: unlimited)").option("--json", "Output as JSON", false).action(async (opts) => {
835
+ const amount = opts.amount ? BigInt(opts.amount) : MAX_UINT256;
836
+ const tx = buildApprovalTx(opts.token, opts.spender, amount, opts.chain);
837
+ outputResult(tx, opts.json, () => {
838
+ return [
839
+ chalk.bold("Approval Transaction"),
840
+ "",
841
+ ` ${chalk.dim("To:")} ${tx.to}`,
842
+ ` ${chalk.dim("Chain:")} ${tx.chainId}`,
843
+ ` ${chalk.dim("Amount:")} ${amount === MAX_UINT256 ? "Unlimited (MaxUint256)" : amount.toString()}`,
844
+ ` ${chalk.dim("Data:")} ${tx.data.slice(0, 20)}...`,
845
+ "",
846
+ chalk.dim("Sign and send this transaction before the deposit/quote transaction.")
847
+ ].join("\n");
848
+ });
849
+ });
850
+ program.command("apy-history").description("Fetch 30-day APY history from DeFiLlama").argument("<slug>", "Vault slug").option("--json", "Output as JSON", false).action(async (slug, opts) => {
851
+ const spinner = ora("Fetching APY history...").start();
852
+ try {
853
+ const forge = getForge();
854
+ const vault = await forge.vaults.get(slug);
855
+ const history = await forge.getApyHistory(vault);
856
+ spinner.stop();
857
+ outputResult({
858
+ vault: vault.slug,
859
+ name: vault.name,
860
+ dataPoints: history.length,
861
+ history
862
+ }, opts.json, () => {
863
+ if (history.length === 0) return chalk.yellow(`No APY history found for ${vault.name}. DeFiLlama may not track this vault.`);
864
+ return `${chalk.bold(`APY History — ${vault.name} (${history.length} days)`)}\n\n${apyHistoryTable(history)}`;
865
+ });
866
+ } catch (err) {
867
+ spinner.fail("Failed to fetch APY history");
868
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
869
+ process.exitCode = 1;
870
+ }
871
+ });
872
+ program.command("preflight").description("Run pre-deposit checks on a vault + wallet").requiredOption("--vault <slug>", "Vault slug").requiredOption("--wallet <addr>", "Wallet address").option("--amount <human>", "Deposit amount (human-readable)").option("--wallet-chain <id>", "Wallet current chain ID", parseInt).option("--cross-chain", "Flag cross-chain deposit intent", false).option("--json", "Output as JSON", false).action(async (opts) => {
873
+ const spinner = ora("Running preflight checks...").start();
874
+ try {
875
+ const forge = getForge();
876
+ const vault = await forge.vaults.get(opts.vault);
877
+ const report = forge.preflight(vault, opts.wallet, {
878
+ walletChainId: opts.walletChain,
879
+ depositAmount: opts.amount,
880
+ crossChain: opts.crossChain
881
+ });
882
+ spinner.stop();
883
+ outputResult({
884
+ ok: report.ok,
885
+ vault: vault.slug,
886
+ wallet: opts.wallet,
887
+ issues: report.issues
888
+ }, opts.json, () => preflightTable(report));
889
+ } catch (err) {
890
+ spinner.fail("Preflight failed");
891
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
892
+ process.exitCode = 1;
893
+ }
894
+ });
895
+ program.command("doctor").description("Run 18-pitfall diagnostics on a vault or environment").option("--vault <slug>", "Vault slug to check").option("--env", "Run environment checks only", false).option("--json", "Output as JSON", false).action(async (opts) => {
896
+ if (opts.env && !opts.vault) {
897
+ const report = runEnvChecks();
898
+ outputResult(report, opts.json, () => formatEnvReport(report));
899
+ return;
900
+ }
901
+ if (!opts.vault) {
902
+ console.error(chalk.red("Provide --vault <slug> or --env"));
903
+ process.exitCode = 1;
904
+ return;
905
+ }
906
+ const spinner = ora("Running doctor checks...").start();
907
+ try {
908
+ const vault = await getForge().vaults.get(opts.vault);
909
+ const report = runDoctorChecks(vault, { hasApiKey: !!process.env["LIFI_API_KEY"] });
910
+ spinner.stop();
911
+ outputResult(report, opts.json, () => formatDoctorReport(report, vault.name));
912
+ } catch (err) {
913
+ spinner.fail("Doctor failed");
914
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
915
+ process.exitCode = 1;
916
+ }
917
+ });
918
+ program.command("risk").description("Calculate risk score for a vault").argument("<slug>", "Vault slug").option("--json", "Output as JSON", false).action(async (slug, opts) => {
919
+ const spinner = ora("Calculating risk...").start();
920
+ try {
921
+ const forge = getForge();
922
+ const vault = await forge.vaults.get(slug);
923
+ const risk = forge.riskScore(vault);
924
+ spinner.stop();
925
+ outputResult({
926
+ slug: vault.slug,
927
+ name: vault.name,
928
+ ...risk
929
+ }, opts.json, () => `${chalk.bold(`Risk Score — ${vault.name}`)}\n\n${riskTable(risk)}`);
930
+ } catch (err) {
931
+ spinner.fail("Failed to calculate risk");
932
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
933
+ process.exitCode = 1;
934
+ }
935
+ });
936
+ program.command("suggest").description("Get portfolio allocation suggestions").requiredOption("--amount <human>", "Total amount to allocate (USD)", parseFloat).requiredOption("--asset <sym>", "Asset symbol (e.g. USDC)").option("--max-chains <n>", "Max number of chains", parseInt).option("--strategy <preset>", "Strategy preset").option("--json", "Output as JSON", false).action(async (opts) => {
937
+ const spinner = ora("Analyzing vaults and building allocations...").start();
938
+ try {
939
+ const forge = getForge();
940
+ const strategy = opts.strategy ? validateStrategy(opts.strategy) : void 0;
941
+ const result = await forge.suggest({
942
+ amount: opts.amount,
943
+ asset: opts.asset,
944
+ maxChains: opts.maxChains,
945
+ strategy
946
+ });
947
+ spinner.stop();
948
+ outputResult({
949
+ totalAmount: result.totalAmount,
950
+ expectedApy: result.expectedApy,
951
+ allocations: result.allocations.map((a) => ({
952
+ vault: a.vault.slug,
953
+ vaultName: a.vault.name,
954
+ chainId: a.vault.chainId,
955
+ protocol: a.vault.protocol.name,
956
+ apy: a.apy,
957
+ risk: a.risk,
958
+ percentage: a.percentage,
959
+ amount: a.amount
960
+ }))
961
+ }, opts.json, () => {
962
+ if (result.allocations.length === 0) return chalk.yellow("No suitable vaults found for allocation.");
963
+ return [
964
+ chalk.bold(`Allocation Suggestion — ${fmtUsd(result.totalAmount)} in ${opts.asset}`),
965
+ ` ${chalk.dim("Expected APY:")} ${chalk.green(fmtPct(result.expectedApy))}`,
966
+ "",
967
+ suggestTable(result.allocations)
968
+ ].join("\n");
969
+ });
970
+ } catch (err) {
971
+ spinner.fail("Failed to generate suggestions");
972
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
973
+ process.exitCode = 1;
974
+ }
975
+ });
976
+ program.command("watch").description("Watch a vault for APY/TVL changes").requiredOption("--vault <slug>", "Vault slug").option("--apy-drop <pct>", "APY drop threshold (%)", parseFloat).option("--tvl-drop <pct>", "TVL drop threshold (%)", parseFloat).option("--interval <ms>", "Poll interval in milliseconds", parseInt).option("--max-iterations <n>", "Max iterations (0 = unlimited)", parseInt).option("--json", "Output events as JSON", false).action(async (opts) => {
977
+ if (!opts.json) console.log(chalk.dim(`Watching ${opts.vault} (Ctrl+C to stop)...`));
978
+ try {
979
+ const forge = getForge();
980
+ const ac = new AbortController();
981
+ const onSignal = () => {
982
+ ac.abort();
983
+ };
984
+ process.on("SIGINT", onSignal);
985
+ process.on("SIGTERM", onSignal);
986
+ const gen = forge.watch(opts.vault, {
987
+ apyDropPercent: opts.apyDrop,
988
+ tvlDropPercent: opts.tvlDrop,
989
+ interval: opts.interval,
990
+ maxIterations: opts.maxIterations,
991
+ signal: ac.signal
992
+ });
993
+ for await (const event of gen) if (opts.json) console.log(JSON.stringify({
994
+ type: event.type,
995
+ vault: event.vault.slug,
996
+ previous: event.previous,
997
+ current: event.current,
998
+ timestamp: event.timestamp.toISOString()
999
+ }));
1000
+ else {
1001
+ const typeColor = event.type === "apy-drop" ? chalk.red : event.type === "tvl-drop" ? chalk.yellow : chalk.dim;
1002
+ const prevApy = event.previous ? fmtPct(event.previous.apy) : "N/A";
1003
+ const prevTvl = event.previous ? fmtUsd(event.previous.tvlUsd) : "N/A";
1004
+ console.log(`[${event.timestamp.toISOString()}] ${typeColor(event.type.toUpperCase())} APY: ${fmtPct(event.current.apy)} (prev: ${prevApy}) TVL: ${fmtUsd(event.current.tvlUsd)} (prev: ${prevTvl})`);
1005
+ }
1006
+ } catch (err) {
1007
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
1008
+ process.exitCode = 1;
1009
+ }
1010
+ });
1011
+ program.command("chains").description("List supported chains").option("--json", "Output as JSON", false).action(async (opts) => {
1012
+ const spinner = ora("Fetching chains...").start();
1013
+ try {
1014
+ const chains = await getForge().chains.list();
1015
+ spinner.stop();
1016
+ outputResult(chains, opts.json, () => `${chalk.bold("Supported Chains")}\n\n${chainTable(chains)}`);
1017
+ } catch (err) {
1018
+ spinner.fail("Failed to fetch chains");
1019
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
1020
+ process.exitCode = 1;
1021
+ }
1022
+ });
1023
+ program.command("protocols").description("List supported protocols").option("--json", "Output as JSON", false).action(async (opts) => {
1024
+ const spinner = ora("Fetching protocols...").start();
1025
+ try {
1026
+ const protocols = await getForge().protocols.list();
1027
+ spinner.stop();
1028
+ outputResult(protocols, opts.json, () => `${chalk.bold("Supported Protocols")}\n\n${protocolTable(protocols)}`);
1029
+ } catch (err) {
1030
+ spinner.fail("Failed to fetch protocols");
1031
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
1032
+ process.exitCode = 1;
1033
+ }
1034
+ });
1035
+ program.command("init").argument("<name>", "Project name").description("Scaffold a new Next.js + wagmi + @earnforge/react project").action(async (name) => {
1036
+ const fs = await import("node:fs");
1037
+ const path = await import("node:path");
1038
+ const dir = path.resolve(process.cwd(), name);
1039
+ if (fs.existsSync(dir)) {
1040
+ console.error(chalk.red(`Directory "${name}" already exists.`));
1041
+ process.exitCode = 1;
1042
+ return;
1043
+ }
1044
+ fs.mkdirSync(dir, { recursive: true });
1045
+ fs.mkdirSync(path.join(dir, "src"), { recursive: true });
1046
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({
1047
+ name,
1048
+ version: "0.1.0",
1049
+ private: true,
1050
+ type: "module",
1051
+ scripts: {
1052
+ dev: "next dev",
1053
+ build: "next build",
1054
+ start: "next start"
1055
+ },
1056
+ dependencies: {
1057
+ "@earnforge/sdk": "^0.1.0",
1058
+ "@earnforge/react": "^0.1.0",
1059
+ "@tanstack/react-query": "^5.90.0",
1060
+ next: "^15.0.0",
1061
+ react: "^19.0.0",
1062
+ "react-dom": "^19.0.0",
1063
+ wagmi: "^2.0.0",
1064
+ viem: "^2.47.0"
1065
+ }
1066
+ }, null, 2));
1067
+ fs.writeFileSync(path.join(dir, ".env.example"), "LIFI_API_KEY=your-api-key-from-portal.li.fi\n");
1068
+ fs.writeFileSync(path.join(dir, "src", "page.tsx"), `// SPDX-License-Identifier: Apache-2.0
1069
+ import { createEarnForge } from '@earnforge/sdk';
1070
+
1071
+ const forge = createEarnForge({
1072
+ composerApiKey: process.env.LIFI_API_KEY,
1073
+ });
1074
+
1075
+ export default async function Home() {
1076
+ const top = await forge.vaults.top({ asset: 'USDC', limit: 5 });
1077
+ return (
1078
+ <main>
1079
+ <h1>Top USDC Vaults</h1>
1080
+ <ul>
1081
+ {top.map((v) => (
1082
+ <li key={v.slug}>
1083
+ {v.name} — {v.analytics.apy.total.toFixed(2)}% APY
1084
+ </li>
1085
+ ))}
1086
+ </ul>
1087
+ </main>
1088
+ );
1089
+ }
1090
+ `);
1091
+ console.log(chalk.green(`\n Scaffolded ${chalk.bold(name)}!\n`));
1092
+ console.log(` ${chalk.dim("cd")} ${name}`);
1093
+ console.log(` ${chalk.dim("cp")} .env.example .env.local`);
1094
+ console.log(` ${chalk.dim("npm install")}`);
1095
+ console.log(` ${chalk.dim("npm run dev")}\n`);
1096
+ });
1097
+ program.command("simulate").description("Dry-run a deposit quote against an anvil fork (requires anvil)").requiredOption("--vault <slug>", "Vault slug").requiredOption("--amount <human>", "Deposit amount (human-readable)").requiredOption("--wallet <addr>", "Wallet address").option("--rpc <url>", "Custom RPC URL (default: auto-detect)").option("--json", "JSON output").action(async (opts) => {
1098
+ const spinner = ora("Running preflight checks...").start();
1099
+ try {
1100
+ const forge = getForge();
1101
+ const vault = await forge.vaults.get(opts.vault);
1102
+ const pre = forge.preflight(vault, opts.wallet, { depositAmount: opts.amount });
1103
+ if (!pre.ok) {
1104
+ spinner.fail("Preflight failed");
1105
+ const errors = pre.issues.filter((i) => i.severity === "error");
1106
+ for (const e of errors) console.error(chalk.red(` [${e.code}] ${e.message}`));
1107
+ process.exitCode = 1;
1108
+ return;
1109
+ }
1110
+ const warnings = pre.issues.filter((i) => i.severity === "warning");
1111
+ for (const w of warnings) console.warn(chalk.yellow(` [${w.code}] ${w.message}`));
1112
+ spinner.text = "Building quote for simulation...";
1113
+ const result = await forge.buildDepositQuote(vault, {
1114
+ fromAmount: opts.amount,
1115
+ wallet: opts.wallet
1116
+ });
1117
+ spinner.text = "Simulating on anvil fork...";
1118
+ const txReq = result.quote.transactionRequest;
1119
+ const rpcUrl = opts.rpc ?? `https://rpc.li.fi/v1/chain/${vault.chainId}`;
1120
+ const sim = await (await globalThis.fetch(rpcUrl, {
1121
+ method: "POST",
1122
+ headers: { "Content-Type": "application/json" },
1123
+ body: JSON.stringify({
1124
+ jsonrpc: "2.0",
1125
+ method: "eth_call",
1126
+ params: [{
1127
+ from: opts.wallet,
1128
+ to: txReq.to,
1129
+ data: txReq.data,
1130
+ value: txReq.value,
1131
+ gas: txReq.gasLimit
1132
+ }, "latest"],
1133
+ id: 1
1134
+ })
1135
+ })).json();
1136
+ spinner.stop();
1137
+ outputResult({
1138
+ vault: vault.slug,
1139
+ amount: opts.amount,
1140
+ decimals: result.decimals,
1141
+ rawAmount: result.rawAmount,
1142
+ gasLimit: txReq.gasLimit,
1143
+ to: txReq.to,
1144
+ chainId: txReq.chainId,
1145
+ simulation: sim.error ? "FAILED" : "SUCCESS",
1146
+ error: sim.error?.message
1147
+ }, opts.json, () => {
1148
+ const status = sim.error ? chalk.red("FAILED: " + sim.error.message) : chalk.green("SUCCESS — transaction would execute");
1149
+ return [
1150
+ chalk.bold("Simulation Result"),
1151
+ "",
1152
+ ` Vault: ${vault.name} (${vault.slug})`,
1153
+ ` Amount: ${opts.amount} (${result.rawAmount} raw)`,
1154
+ ` Gas Limit: ${txReq.gasLimit}`,
1155
+ ` Target: ${txReq.to}`,
1156
+ ` Chain: ${txReq.chainId}`,
1157
+ "",
1158
+ ` Status: ${status}`
1159
+ ].join("\n");
1160
+ });
1161
+ } catch (err) {
1162
+ spinner.fail("Simulation failed");
1163
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
1164
+ process.exitCode = 1;
1165
+ }
1166
+ });
1167
+ //#endregion
1168
+ export { formatEnvReport as a, formatDoctorReport as i, resetForge as n, runDoctorChecks as o, setForge as r, runEnvChecks as s, program as t };
1169
+
1170
+ //# sourceMappingURL=src-kbE_XVj7.mjs.map