@solard/cli 0.2.6 → 0.2.8

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,2356 @@
1
+ import type {
2
+ MeteoraPreparedTransactions,
3
+ MeteoraStrategy,
4
+ MeteoraTimeframe,
5
+ Solard,
6
+ } from "@solard/sdk";
7
+
8
+ const WSOL = "So11111111111111111111111111111111111111112";
9
+
10
+ type Flags = Map<string, string>;
11
+ type Emit = (value: string) => void;
12
+ type AnyRow = Record<string, any>;
13
+
14
+ function json(value: unknown): string {
15
+ return JSON.stringify(
16
+ value,
17
+ (_, item) => (typeof item === "bigint" ? item.toString() : item),
18
+ 2,
19
+ );
20
+ }
21
+
22
+ function flag(flags: Flags, key: string): string | undefined {
23
+ const value = flags.get(key);
24
+ return value && value !== "true" ? value : undefined;
25
+ }
26
+
27
+ function requiredFlag(flags: Flags, key: string): string {
28
+ const value = flag(flags, key);
29
+ if (!value) throw new Error(`Missing --${key} <value>`);
30
+ return value;
31
+ }
32
+
33
+ function numberFlag(
34
+ flags: Flags,
35
+ key: string,
36
+ fallback?: number,
37
+ ): number | undefined {
38
+ const value = flag(flags, key);
39
+ if (value == null) return fallback;
40
+ const parsed = Number(value);
41
+ if (!Number.isFinite(parsed)) throw new Error(`Invalid --${key}: ${value}`);
42
+ return parsed;
43
+ }
44
+
45
+ function integerFlag(
46
+ flags: Flags,
47
+ key: string,
48
+ fallback?: number,
49
+ ): number | undefined {
50
+ const value = numberFlag(flags, key, fallback);
51
+ if (value == null) return undefined;
52
+ if (!Number.isInteger(value)) throw new Error(`--${key} must be an integer`);
53
+ return value;
54
+ }
55
+
56
+ function positiveIntegerFlag(
57
+ flags: Flags,
58
+ key: string,
59
+ fallback?: number,
60
+ ): number | undefined {
61
+ const value = integerFlag(flags, key, fallback);
62
+ if (value == null) return undefined;
63
+ if (value <= 0) throw new Error(`--${key} must be > 0`);
64
+ return value;
65
+ }
66
+
67
+ function timeframe(
68
+ flags: Flags,
69
+ fallback: MeteoraTimeframe = "30m",
70
+ ): MeteoraTimeframe {
71
+ const value = flag(flags, "timeframe") ?? flag(flags, "interval") ?? fallback;
72
+ if (!["5m", "30m", "1h", "2h", "4h", "12h", "24h"].includes(value)) {
73
+ throw new Error(
74
+ "Meteora timeframe must be 5m, 30m, 1h, 2h, 4h, 12h, or 24h",
75
+ );
76
+ }
77
+ return value as MeteoraTimeframe;
78
+ }
79
+
80
+ function strategy(flags: Flags): MeteoraStrategy {
81
+ const value = flag(flags, "strategy") ?? "spot";
82
+ if (value !== "spot" && value !== "bid_ask" && value !== "curve") {
83
+ throw new Error("--strategy must be spot, bid_ask, or curve");
84
+ }
85
+ return value;
86
+ }
87
+
88
+ function publicWalletAddress(slrd: Solard, ref: string): string {
89
+ try {
90
+ return slrd.resolveWallet(ref).address.toBase58();
91
+ } catch {
92
+ // Read-only Meteora endpoints accept a raw public address. The downstream
93
+ // PublicKey parser remains the authoritative validator.
94
+ return ref.trim();
95
+ }
96
+ }
97
+
98
+ function preparedSummary(prepared: MeteoraPreparedTransactions) {
99
+ return {
100
+ prepared: true,
101
+ live: false,
102
+ kind: prepared.kind,
103
+ pool: prepared.pool,
104
+ position: prepared.position ?? null,
105
+ transactionCount: prepared.transactions.length,
106
+ extraSignerCount: prepared.extraSigners.length,
107
+ metadata: prepared.metadata ?? null,
108
+ note:
109
+ prepared.kind === "open-position"
110
+ ? "Prepared only. The generated position key is ephemeral; rebuilding later may produce a different position address. Add --live to build, simulate, and broadcast in one invocation."
111
+ : "Prepared only. Add --live to simulate and broadcast.",
112
+ };
113
+ }
114
+
115
+ async function finishWrite(
116
+ slrd: Solard,
117
+ prepared: MeteoraPreparedTransactions,
118
+ flags: Flags,
119
+ ): Promise<unknown> {
120
+ if (!flags.has("live")) return preparedSummary(prepared);
121
+ return await slrd.meteora.executePrepared(prepared, {
122
+ live: true,
123
+ simulate: !flags.has("skip-simulation"),
124
+ skipPreflight: flags.has("skip-preflight"),
125
+ commitment: (flag(flags, "commitment") as any) ?? "confirmed",
126
+ maxRetries: integerFlag(flags, "max-retries"),
127
+ });
128
+ }
129
+
130
+ function appendFilter(parts: string[], expression: string | undefined): void {
131
+ const clean = expression?.trim();
132
+ if (clean) parts.push(clean);
133
+ }
134
+
135
+ function poolSort(flags: Flags, tf: MeteoraTimeframe): string | undefined {
136
+ const sort = flag(flags, "sort");
137
+ if (!sort) return undefined;
138
+ const direction = (flag(flags, "direction") ?? "desc").toLowerCase();
139
+ if (direction !== "asc" && direction !== "desc") {
140
+ throw new Error("--direction must be asc or desc");
141
+ }
142
+ if (sort.includes(":")) return sort;
143
+ if (sort === "fee-active-tvl") {
144
+ throw new Error(
145
+ "fee-active-tvl is a Pool Discovery metric. Use: slrd meteora discover --timeframe <window> --sort fee-active-tvl",
146
+ );
147
+ }
148
+ if (sort === "fee-tvl" || sort === "fee-tvl-ratio") {
149
+ return `fee_tvl_ratio_${tf}:${direction}`;
150
+ }
151
+ if (sort === "volume") return `volume_${tf}:${direction}`;
152
+ if (sort === "tvl") return `tvl:${direction}`;
153
+ return `${sort}:${direction}`;
154
+ }
155
+
156
+ function listRows(payload: unknown): AnyRow[] {
157
+ if (Array.isArray(payload)) return payload as AnyRow[];
158
+ const row = payload as AnyRow | null;
159
+ if (Array.isArray(row?.data)) return row!.data;
160
+ if (Array.isArray(row?.pools)) return row!.pools;
161
+ return [];
162
+ }
163
+
164
+ function pickMetric(row: AnyRow, key: string, tf: MeteoraTimeframe): unknown {
165
+ return (
166
+ row?.[`${key}_${tf}`] ??
167
+ row?.[key]?.[tf] ??
168
+ row?.metrics?.[key]?.[tf] ??
169
+ null
170
+ );
171
+ }
172
+
173
+ function poolTable(payload: unknown, tf: MeteoraTimeframe): string {
174
+ const rows = listRows(payload);
175
+ if (!rows.length) return json(payload) + "\n";
176
+ const lines = [
177
+ "POOL\tNAME\tFEE/TVL\tVOLUME\tTVL\tBIN",
178
+ ...rows.map((row) => {
179
+ const address = String(row.address ?? row.pool_address ?? row.pool ?? "");
180
+ const ratio = pickMetric(row, "fee_tvl_ratio", tf);
181
+ const volume = pickMetric(row, "volume", tf);
182
+ const tvl = row.active_tvl ?? row.tvl ?? row.liquidity ?? null;
183
+ const bin =
184
+ row.bin_step ??
185
+ row.dlmm_params?.bin_step ??
186
+ row.pool_config?.bin_step ??
187
+ null;
188
+ return [
189
+ address,
190
+ String(row.name ?? ""),
191
+ ratio == null ? "" : String(ratio),
192
+ volume == null ? "" : String(volume),
193
+ tvl == null ? "" : String(tvl),
194
+ bin == null ? "" : String(bin),
195
+ ].join("\t");
196
+ }),
197
+ ];
198
+ return lines.join("\n") + "\n";
199
+ }
200
+
201
+ function discoveryAddress(row: AnyRow): string {
202
+ return String(row.pool_address ?? row.address ?? row.pool ?? "");
203
+ }
204
+
205
+ function finiteMetric(value: unknown): number | null {
206
+ const parsed = Number(value);
207
+ return Number.isFinite(parsed) ? parsed : null;
208
+ }
209
+
210
+ function usd(value: unknown): string {
211
+ const amount = finiteMetric(value);
212
+ if (amount == null) return "-";
213
+ const absolute = Math.abs(amount);
214
+ const sign = amount < 0 ? "-" : "";
215
+ if (absolute >= 1_000_000_000)
216
+ return `${sign}$${(absolute / 1_000_000_000).toFixed(2)}B`;
217
+ if (absolute >= 1_000_000)
218
+ return `${sign}$${(absolute / 1_000_000).toFixed(2)}M`;
219
+ if (absolute >= 1_000) return `${sign}$${(absolute / 1_000).toFixed(2)}K`;
220
+ return `${sign}$${absolute.toFixed(2)}`;
221
+ }
222
+
223
+ function percentRatio(value: unknown): string {
224
+ // Pool Discovery already reports fee_active_tvl_ratio in percentage units.
225
+ // Example: fee=$6.75K and active_tvl=$37.21K => 18.14, not 0.1814.
226
+ const percent = finiteMetric(value);
227
+ if (percent == null) return "-";
228
+ return `${percent.toLocaleString("en-US", {
229
+ minimumFractionDigits: 2,
230
+ maximumFractionDigits: 2,
231
+ })}%`;
232
+ }
233
+
234
+ function pad(
235
+ value: unknown,
236
+ width: number,
237
+ align: "left" | "right" = "left",
238
+ ): string {
239
+ const text = String(value ?? "");
240
+ if (text.length >= width) return text.slice(0, width);
241
+ return align === "right" ? text.padStart(width) : text.padEnd(width);
242
+ }
243
+
244
+ function shortId(value: unknown, head = 6, tail = 6): string {
245
+ const text = String(value ?? "");
246
+ if (text.length <= head + tail + 1) return text;
247
+ return `${text.slice(0, head)}…${text.slice(-tail)}`;
248
+ }
249
+
250
+ function rawUiText(raw: unknown, decimals: unknown, precision = 6): string {
251
+ const text = String(raw ?? "0");
252
+ const d = Number(decimals ?? 0);
253
+ if (!/^\d+$/.test(text) || !Number.isInteger(d) || d < 0) return text;
254
+ const value = BigInt(text);
255
+ if (d === 0) return value.toString();
256
+ const scale = 10n ** BigInt(d);
257
+ const whole = value / scale;
258
+ const frac = (value % scale)
259
+ .toString()
260
+ .padStart(d, "0")
261
+ .slice(0, Math.min(d, precision))
262
+ .replace(/0+$/, "");
263
+ return frac ? `${whole}.${frac}` : whole.toString();
264
+ }
265
+
266
+ function numberText(value: unknown, digits = 6): string {
267
+ const n = Number(value);
268
+ if (!Number.isFinite(n)) return "-";
269
+ if (n === 0) return "0";
270
+ if (Math.abs(n) >= 1_000)
271
+ return n.toLocaleString("en-US", { maximumFractionDigits: 2 });
272
+ return n.toLocaleString("en-US", { maximumSignificantDigits: digits });
273
+ }
274
+
275
+ function poolDetailText(payload: AnyRow): string {
276
+ const on = payload.onChain ?? {};
277
+ const indexed = payload.indexed ?? {};
278
+ const discovery = payload.discovery ?? {};
279
+ const x = indexed.token_x ?? discovery.token_x ?? on.tokenX ?? {};
280
+ const y = indexed.token_y ?? discovery.token_y ?? on.tokenY ?? {};
281
+ const tf = String(payload.timeframe ?? "30m");
282
+ const volume = indexed.volume?.[tf] ?? discovery.volume ?? null;
283
+ const fees = indexed.fees?.[tf] ?? discovery.fee ?? null;
284
+ const activeTvl = discovery.active_tvl ?? null;
285
+ const totalTvl = discovery.tvl ?? indexed.tvl ?? null;
286
+ const lines = [
287
+ `POOL ${indexed.name ?? discovery.name ?? shortId(payload.pool)} ${payload.pool}`,
288
+ `PAIR ${x.symbol ?? x.name ?? shortId(x.address ?? on.tokenX?.mint)} / ${y.symbol ?? y.name ?? shortId(y.address ?? on.tokenY?.mint)}`,
289
+ `ACTIVE bin=${on.activeId ?? on.activeBin?.binId ?? "-"} price=${numberText(on.activeBin?.price ?? indexed.current_price ?? discovery.pool_price, 8)} bin-step=${on.binStep ?? indexed.pool_config?.bin_step ?? discovery.dlmm_params?.bin_step ?? "-"}`,
290
+ `FEES base=${numberText(indexed.pool_config?.base_fee_pct ?? discovery.fee_pct, 5)}% dynamic=${numberText(indexed.dynamic_fee_pct ?? discovery.dynamic_fee_pct, 5)}% ${tf}=${usd(fees)}`,
291
+ `FLOW volume=${usd(volume)} active=${usd(activeTvl)} total=${usd(totalTvl)} fee/active=${percentRatio(discovery.fee_active_tvl_ratio)}`,
292
+ ];
293
+ if (x.holders != null || discovery.base_token_holders != null) {
294
+ lines.push(
295
+ `TOKEN holders=${Number(x.holders ?? discovery.base_token_holders).toLocaleString("en-US")} verified=${x.is_verified ?? "-"} top-holders=${x.top_holders_pct == null ? "-" : `${numberText(x.top_holders_pct, 5)}%`} dev=${x.dev_balance_pct == null ? "-" : `${numberText(x.dev_balance_pct, 5)}%`}`,
296
+ );
297
+ }
298
+ if (discovery.swap_count != null) {
299
+ lines.push(
300
+ `ACTIVITY swaps=${discovery.swap_count} traders=${discovery.unique_traders ?? "-"} LPs=${discovery.unique_lps ?? "-"} price-change=${discovery.pool_price_change_pct == null ? "-" : `${numberText(discovery.pool_price_change_pct, 5)}%`}`,
301
+ );
302
+ }
303
+ return lines.join("\n") + "\n";
304
+ }
305
+
306
+ function candlesTable(payload: AnyRow): string {
307
+ const rows = Array.isArray(payload?.data) ? payload.data : [];
308
+ if (!rows.length) return `No ${payload?.timeframe ?? ""} candles.\n`;
309
+ const lines = [
310
+ [
311
+ pad("TIME", 20),
312
+ pad("OPEN", 14, "right"),
313
+ pad("HIGH", 14, "right"),
314
+ pad("LOW", 14, "right"),
315
+ pad("CLOSE", 14, "right"),
316
+ pad("VOLUME", 12, "right"),
317
+ ].join(" "),
318
+ ];
319
+ for (const row of rows) {
320
+ const time = String(row.timestamp_str ?? row.timestamp ?? "").replace(
321
+ "+00:00",
322
+ "Z",
323
+ );
324
+ lines.push(
325
+ [
326
+ pad(time, 20),
327
+ pad(numberText(row.open, 7), 14, "right"),
328
+ pad(numberText(row.high, 7), 14, "right"),
329
+ pad(numberText(row.low, 7), 14, "right"),
330
+ pad(numberText(row.close, 7), 14, "right"),
331
+ pad(usd(row.volume), 12, "right"),
332
+ ].join(" "),
333
+ );
334
+ }
335
+ return lines.join("\n") + "\n";
336
+ }
337
+
338
+ function activeBinText(row: AnyRow): string {
339
+ return `ACTIVE BIN ${row.binId ?? "-"} price=${numberText(row.price, 9)} pool=${row.pool ?? "-"}\n`;
340
+ }
341
+
342
+ function positionsTable(result: AnyRow): string {
343
+ const rows = Array.isArray(result?.positions) ? result.positions : [];
344
+ if (!rows.length) return `METEORA POSITIONS 0\n`;
345
+ const lines = [
346
+ `METEORA POSITIONS ${rows.length}${result.wallet ? ` wallet=${result.wallet}` : ""}`,
347
+ [
348
+ pad("POSITION", 15),
349
+ pad("RANGE", 14),
350
+ pad("ACTIVE", 7, "right"),
351
+ pad("IN", 3),
352
+ pad("X", 16, "right"),
353
+ pad("Y", 14, "right"),
354
+ pad("FEE X", 12, "right"),
355
+ pad("FEE Y", 12, "right"),
356
+ ].join(" "),
357
+ ];
358
+ for (const row of rows) {
359
+ const xd = row.tokenX?.decimals ?? 0;
360
+ const yd = row.tokenY?.decimals ?? 0;
361
+ lines.push(
362
+ [
363
+ pad(shortId(row.position, 7, 7), 15),
364
+ pad(`${row.lowerBin ?? "?"}..${row.upperBin ?? "?"}`, 14),
365
+ pad(row.activeBin ?? "-", 7, "right"),
366
+ pad(row.inRange ? "yes" : "no", 3),
367
+ pad(rawUiText(row.totalXRaw ?? row.xRaw, xd), 16, "right"),
368
+ pad(rawUiText(row.totalYRaw ?? row.yRaw, yd), 14, "right"),
369
+ pad(rawUiText(row.feeXRaw, xd), 12, "right"),
370
+ pad(rawUiText(row.feeYRaw, yd), 12, "right"),
371
+ ].join(" "),
372
+ );
373
+ }
374
+ return lines.join("\n") + "\n";
375
+ }
376
+
377
+ function portfolioText(result: AnyRow): string {
378
+ const total = result?.total ?? {};
379
+ const lines = [
380
+ `METEORA PORTFOLIO positions=${result?.totalPositions ?? total.totalPositions ?? 0} value=${numberText(total.balancesSol, 7)} SOL fees=${numberText(total.unclaimedFeesSol, 7)} SOL pnl=${numberText(total.pnlSol, 7)} SOL (${numberText(total.pnlSolPctChange, 5)}%)`,
381
+ ];
382
+ const pools = Array.isArray(result?.pools) ? result.pools : [];
383
+ if (pools.length) {
384
+ lines.push(
385
+ [
386
+ pad("POOL", 15),
387
+ pad("PAIR", 20),
388
+ pad("POS", 4, "right"),
389
+ pad("VALUE SOL", 11, "right"),
390
+ pad("FEES SOL", 10, "right"),
391
+ pad("PNL SOL", 10, "right"),
392
+ pad("OOR", 3),
393
+ ].join(" "),
394
+ );
395
+ for (const row of pools) {
396
+ lines.push(
397
+ [
398
+ pad(shortId(row.poolAddress, 7, 7), 15),
399
+ pad(`${row.tokenX ?? "?"}-${row.tokenY ?? "?"}`, 20),
400
+ pad(row.openPositionCount ?? "-", 4, "right"),
401
+ pad(numberText(row.balancesSol, 7), 11, "right"),
402
+ pad(numberText(row.unclaimedFeesSol, 7), 10, "right"),
403
+ pad(numberText(row.pnlSol, 7), 10, "right"),
404
+ pad(row.outOfRange ? "yes" : "no", 3),
405
+ ].join(" "),
406
+ );
407
+ }
408
+ }
409
+ return lines.join("\n") + "\n";
410
+ }
411
+
412
+ function discoveryIdentity(row: AnyRow): string {
413
+ const address = discoveryAddress(row).trim();
414
+ if (address) return address;
415
+ return JSON.stringify([
416
+ row.name ?? null,
417
+ row.token_x?.address ?? null,
418
+ row.token_y?.address ?? null,
419
+ ]);
420
+ }
421
+
422
+ function sameDiscoveryPage(a: AnyRow[], b: AnyRow[]): boolean {
423
+ if (a.length !== b.length || a.length === 0) return false;
424
+ return a.every(
425
+ (row, index) => discoveryIdentity(row) === discoveryIdentity(b[index]),
426
+ );
427
+ }
428
+
429
+ function discoveryTable(rows: AnyRow[], timeframe: MeteoraTimeframe): string {
430
+ if (!rows.length) return "No discovery pools matched.\n";
431
+ const feeLabel = `FEE ${timeframe.toUpperCase()}`;
432
+ const volumeLabel = `VOLUME ${timeframe.toUpperCase()}`;
433
+ const header = [
434
+ pad("NAME", 22),
435
+ pad("FEE/ACTIVE", 12, "right"),
436
+ pad(feeLabel, 12, "right"),
437
+ pad("ACTIVE TVL", 12, "right"),
438
+ pad("TOTAL TVL", 12, "right"),
439
+ pad(volumeLabel, 12, "right"),
440
+ pad("BIN", 5, "right"),
441
+ "POOL",
442
+ ].join(" ");
443
+ const body = rows.map((row) =>
444
+ [
445
+ pad(String(row.name ?? "-"), 22),
446
+ pad(percentRatio(row.fee_active_tvl_ratio), 12, "right"),
447
+ pad(usd(row.fee), 12, "right"),
448
+ pad(usd(row.active_tvl), 12, "right"),
449
+ pad(usd(row.tvl), 12, "right"),
450
+ pad(usd(row.volume), 12, "right"),
451
+ pad(row.dlmm_params?.bin_step ?? row.bin_step ?? "-", 5, "right"),
452
+ discoveryAddress(row),
453
+ ].join(" "),
454
+ );
455
+ return [header, ...body].join("\n") + "\n";
456
+ }
457
+
458
+ function ratioX(numerator: unknown, denominator: unknown): number | null {
459
+ const n = finiteMetric(numerator);
460
+ const d = finiteMetric(denominator);
461
+ if (n == null || d == null || d <= 0) return null;
462
+ return n / d;
463
+ }
464
+
465
+ function inactiveLiquidityPercent(row: AnyRow): number | null {
466
+ const active = finiteMetric(row.active_tvl);
467
+ const total = finiteMetric(row.tvl);
468
+ if (active == null || total == null || active < 0 || total <= 0) return null;
469
+ // Discovery values are not necessarily sampled atomically. If active TVL is
470
+ // greater than total TVL, do not manufacture a negative "inactive" value.
471
+ if (active > total) return null;
472
+ return ((total - active) / total) * 100;
473
+ }
474
+
475
+ function opportunityMetrics(row: AnyRow) {
476
+ const inactivePct = inactiveLiquidityPercent(row);
477
+ const volumeActive = ratioX(row.volume, row.active_tvl);
478
+ const feeActive =
479
+ finiteMetric(row.fee_active_tvl_ratio) ??
480
+ (() => {
481
+ const ratio = ratioX(row.fee, row.active_tvl);
482
+ return ratio == null ? null : ratio * 100;
483
+ })();
484
+ const feeVolume = (() => {
485
+ const ratio = ratioX(row.fee, row.volume);
486
+ return ratio == null ? null : ratio * 100;
487
+ })();
488
+ const flowInactive =
489
+ inactivePct == null || volumeActive == null
490
+ ? null
491
+ : volumeActive * (inactivePct / 100);
492
+ return { inactivePct, volumeActive, feeActive, feeVolume, flowInactive };
493
+ }
494
+
495
+ const QUOTE_MINTS = new Set([
496
+ WSOL,
497
+ "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC
498
+ ]);
499
+
500
+ function gmgnRiskNumber(row: AnyRow, ...keys: string[]): number | null {
501
+ for (const key of keys) {
502
+ const value = finiteMetric(row?.[key]);
503
+ if (value != null) return value;
504
+ }
505
+ return null;
506
+ }
507
+
508
+ function gmgnRiskObject(payload: unknown): AnyRow {
509
+ if (!payload || typeof payload !== "object") return {};
510
+ if (Array.isArray(payload))
511
+ return (
512
+ payload[0] && typeof payload[0] === "object" ? payload[0] : {}
513
+ ) as AnyRow;
514
+ const body = payload as AnyRow;
515
+ if (body.security && typeof body.security === "object")
516
+ return body.security as AnyRow;
517
+ if (body.data && typeof body.data === "object" && !Array.isArray(body.data))
518
+ return body.data as AnyRow;
519
+ return body;
520
+ }
521
+
522
+ function gmgnHolderRows(payload: unknown): AnyRow[] {
523
+ if (Array.isArray(payload)) return payload as AnyRow[];
524
+ if (!payload || typeof payload !== "object") return [];
525
+ const body = payload as AnyRow;
526
+ if (Array.isArray(body.holders)) return body.holders;
527
+ if (Array.isArray(body.data)) return body.data;
528
+ if (Array.isArray(body.list)) return body.list;
529
+ return [];
530
+ }
531
+
532
+ function holderTags(row: AnyRow): string[] {
533
+ const values = [
534
+ ...(Array.isArray(row.maker_token_tags) ? row.maker_token_tags : []),
535
+ ...(Array.isArray(row.tags) ? row.tags : []),
536
+ ];
537
+ return values
538
+ .map((value: any) =>
539
+ String(value?.name ?? value?.id ?? value ?? "").toLowerCase(),
540
+ )
541
+ .filter(Boolean);
542
+ }
543
+
544
+ function taggedHolderRate(holders: AnyRow[], tags: string[]): number | null {
545
+ if (!holders.length) return null;
546
+ let sum = 0;
547
+ for (const holder of holders) {
548
+ const normalized = holderTags(holder);
549
+ if (!normalized.some((tag) => tags.some((needle) => tag.includes(needle))))
550
+ continue;
551
+ const pct = gmgnRate(holder, "amount_percentage", "percentage", "pct");
552
+ if (pct != null) sum += pct;
553
+ }
554
+ return sum;
555
+ }
556
+
557
+ function rateValue(value: unknown): number | null {
558
+ const n = finiteMetric(value);
559
+ if (n == null || n < 0) return null;
560
+ // GMGN ratios are documented as 0..1. Tolerate percentage-shaped payloads
561
+ // without allowing impossible values to silently pass the screen.
562
+ if (n <= 1) return n;
563
+ if (n <= 100) return n / 100;
564
+ return null;
565
+ }
566
+
567
+ function gmgnRate(row: AnyRow, ...keys: string[]): number | null {
568
+ for (const key of keys) {
569
+ const n = rateValue(row?.[key]);
570
+ if (n != null) return n;
571
+ }
572
+ return null;
573
+ }
574
+
575
+ function boolFalse(value: unknown): boolean {
576
+ return (
577
+ value === false ||
578
+ String(value).toLowerCase() === "false" ||
579
+ String(value).toLowerCase() === "no"
580
+ );
581
+ }
582
+
583
+ function riskMintForRow(row: AnyRow): string | null {
584
+ const x = String(row.token_x?.address ?? row.token_x?.mint ?? "").trim();
585
+ const y = String(row.token_y?.address ?? row.token_y?.mint ?? "").trim();
586
+ if (x && !QUOTE_MINTS.has(x)) return x;
587
+ if (y && !QUOTE_MINTS.has(y)) return y;
588
+ return x || y || null;
589
+ }
590
+
591
+ type DirectRiskThresholds = {
592
+ maxRug: number;
593
+ maxBundler: number;
594
+ maxInsider: number;
595
+ maxTop10: number;
596
+ maxSniper: number;
597
+ maxDev: number;
598
+ minHolders: number;
599
+ };
600
+
601
+ type DirectRiskAssessment = {
602
+ mint: string;
603
+ passed: boolean;
604
+ reasons: string[];
605
+ metrics: AnyRow;
606
+ };
607
+
608
+ function directRiskThresholds(flags: Flags): DirectRiskThresholds {
609
+ return {
610
+ maxRug: Math.max(0, numberFlag(flags, "max-rug", 0.2)!),
611
+ maxBundler: Math.max(0, numberFlag(flags, "max-bundler", 0.15)!),
612
+ maxInsider: Math.max(0, numberFlag(flags, "max-insider", 0.15)!),
613
+ maxTop10: Math.max(0, numberFlag(flags, "max-top10", 0.25)!),
614
+ maxSniper: Math.max(0, numberFlag(flags, "max-sniper", 0.15)!),
615
+ maxDev: Math.max(0, numberFlag(flags, "max-dev", 0.05)!),
616
+ minHolders: Math.max(0, integerFlag(flags, "min-holders", 100)!),
617
+ };
618
+ }
619
+
620
+ async function assessDirectGmgnRisk(
621
+ slrd: Solard,
622
+ row: AnyRow,
623
+ thresholds: DirectRiskThresholds,
624
+ ): Promise<DirectRiskAssessment> {
625
+ const mint = riskMintForRow(row);
626
+ if (!mint)
627
+ return { mint: "", passed: false, reasons: ["mint_missing"], metrics: {} };
628
+
629
+ const [securityRaw, holdersRaw] = await Promise.all([
630
+ slrd.gmgn.tokenSecurity("sol", mint),
631
+ slrd.gmgn.tokenTopHolders("sol", mint, { limit: 100 }),
632
+ ]);
633
+ const security = gmgnRiskObject(securityRaw);
634
+ const holders = gmgnHolderRows(holdersRaw);
635
+
636
+ const rug = gmgnRate(security, "rug_ratio");
637
+ const bundlerCandidates = [
638
+ gmgnRate(security, "bundler_trader_amount_rate", "bundler_rate"),
639
+ taggedHolderRate(holders, ["bundler"]),
640
+ ].filter((value): value is number => value != null);
641
+ const bundler = bundlerCandidates.length
642
+ ? Math.max(...bundlerCandidates)
643
+ : null;
644
+ const insiderCandidates = [
645
+ gmgnRate(security, "suspected_insider_hold_rate"),
646
+ gmgnRate(security, "rat_trader_amount_rate"),
647
+ gmgnRate(security, "insider_ratio"),
648
+ taggedHolderRate(holders, ["rat_trader", "insider"]),
649
+ ].filter((value): value is number => value != null);
650
+ const insider = insiderCandidates.length
651
+ ? Math.max(...insiderCandidates)
652
+ : null;
653
+ const top10 = gmgnRate(security, "top_10_holder_rate", "top_holder_rate");
654
+ const devCandidates = [
655
+ gmgnRate(security, "dev_team_hold_rate"),
656
+ gmgnRate(security, "creator_balance_rate"),
657
+ ].filter((value): value is number => value != null);
658
+ const dev = devCandidates.length ? Math.max(...devCandidates) : null;
659
+
660
+ let sniper = gmgnRate(
661
+ security,
662
+ "top70_sniper_hold_rate",
663
+ "top_70_sniper_hold_rate",
664
+ );
665
+ if (sniper == null) sniper = taggedHolderRate(holders, ["sniper"]);
666
+
667
+ const holdersCount = finiteMetric(
668
+ row.base_token_holders ?? row.token_x?.holders ?? row.token_y?.holders,
669
+ );
670
+ const wash =
671
+ security.is_wash_trading === true ||
672
+ String(security.is_wash_trading).toLowerCase() === "true";
673
+ const reasons: string[] = [];
674
+ const requireRate = (name: string, value: number | null, cap: number) => {
675
+ if (value == null) reasons.push(`${name}_missing`);
676
+ else if (value > cap) reasons.push(name);
677
+ };
678
+ requireRate("rug", rug, thresholds.maxRug);
679
+ requireRate("bundler", bundler, thresholds.maxBundler);
680
+ requireRate("insider", insider, thresholds.maxInsider);
681
+ requireRate("top10", top10, thresholds.maxTop10);
682
+ requireRate("sniper", sniper, thresholds.maxSniper);
683
+ requireRate("dev", dev, thresholds.maxDev);
684
+ if (holdersCount == null) reasons.push("holders_missing");
685
+ else if (holdersCount < thresholds.minHolders) reasons.push("holders");
686
+ if (wash) reasons.push("wash_trading");
687
+ if ("renounced_mint" in security && boolFalse(security.renounced_mint))
688
+ reasons.push("mint_authority");
689
+ if (
690
+ "renounced_freeze_account" in security &&
691
+ boolFalse(security.renounced_freeze_account)
692
+ )
693
+ reasons.push("freeze_authority");
694
+
695
+ return {
696
+ mint,
697
+ passed: reasons.length === 0,
698
+ reasons,
699
+ metrics: {
700
+ rug,
701
+ bundler,
702
+ insider,
703
+ top10,
704
+ sniper,
705
+ dev,
706
+ holders: holdersCount,
707
+ wash,
708
+ },
709
+ };
710
+ }
711
+
712
+ async function mapWithConcurrency<T, R>(
713
+ items: T[],
714
+ concurrency: number,
715
+ fn: (item: T) => Promise<R>,
716
+ ): Promise<R[]> {
717
+ const results = new Array<R>(items.length);
718
+ let cursor = 0;
719
+ const workers = Array.from(
720
+ { length: Math.max(1, Math.min(concurrency, items.length || 1)) },
721
+ async () => {
722
+ while (true) {
723
+ const index = cursor++;
724
+ if (index >= items.length) return;
725
+ results[index] = await fn(items[index]!);
726
+ }
727
+ },
728
+ );
729
+ await Promise.all(workers);
730
+ return results;
731
+ }
732
+
733
+ async function directRiskScreenRows(
734
+ slrd: Solard,
735
+ rows: AnyRow[],
736
+ flags: Flags,
737
+ outputLimit: number,
738
+ ): Promise<{
739
+ rows: AnyRow[];
740
+ thresholds: DirectRiskThresholds;
741
+ scanned: number;
742
+ passed: number;
743
+ rejects: Record<string, number>;
744
+ }> {
745
+ if (!slrd.gmgn.configured()) {
746
+ throw new Error(
747
+ "--risk-screen requires GMGN_API_KEY; refusing to treat missing bundler/insider data as safe",
748
+ );
749
+ }
750
+ const thresholds = directRiskThresholds(flags);
751
+ const scanLimit = Math.max(
752
+ outputLimit,
753
+ Math.min(
754
+ 60,
755
+ positiveIntegerFlag(
756
+ flags,
757
+ "risk-scan-limit",
758
+ Math.max(30, outputLimit * 2),
759
+ )!,
760
+ ),
761
+ );
762
+ const candidates = rows.slice(0, scanLimit);
763
+ const assessments = await mapWithConcurrency(candidates, 4, async (row) => {
764
+ try {
765
+ return await assessDirectGmgnRisk(slrd, row, thresholds);
766
+ } catch (error) {
767
+ return {
768
+ mint: riskMintForRow(row) ?? "",
769
+ passed: false,
770
+ reasons: ["gmgn_error"],
771
+ metrics: {
772
+ error: error instanceof Error ? error.message : String(error),
773
+ },
774
+ } satisfies DirectRiskAssessment;
775
+ }
776
+ });
777
+ const rejects: Record<string, number> = {};
778
+ const passedRows: AnyRow[] = [];
779
+ for (let index = 0; index < candidates.length; index += 1) {
780
+ const assessment = assessments[index]!;
781
+ if (assessment.passed) {
782
+ passedRows.push({ ...candidates[index], risk_screen: assessment });
783
+ continue;
784
+ }
785
+ for (const reason of assessment.reasons)
786
+ rejects[reason] = (rejects[reason] ?? 0) + 1;
787
+ }
788
+ return {
789
+ rows: passedRows.slice(0, outputLimit),
790
+ thresholds,
791
+ scanned: candidates.length,
792
+ passed: passedRows.length,
793
+ rejects,
794
+ };
795
+ }
796
+
797
+ function riskRejectSummary(rejects: Record<string, number>): string {
798
+ const rows = Object.entries(rejects).sort((a, b) => b[1] - a[1]);
799
+ return rows.length
800
+ ? rows.map(([reason, count]) => `${reason}=${count}`).join(", ")
801
+ : "none";
802
+ }
803
+
804
+ function multiple(value: unknown, digits = 1): string {
805
+ const n = finiteMetric(value);
806
+ if (n == null) return "-";
807
+ return `${n.toLocaleString("en-US", {
808
+ minimumFractionDigits: digits,
809
+ maximumFractionDigits: digits,
810
+ })}x`;
811
+ }
812
+
813
+ function metricPercent(value: unknown): string {
814
+ const n = finiteMetric(value);
815
+ if (n == null) return "-";
816
+ return `${n.toLocaleString("en-US", {
817
+ minimumFractionDigits: 1,
818
+ maximumFractionDigits: 1,
819
+ })}%`;
820
+ }
821
+
822
+ function opportunitySortField(
823
+ sort: string,
824
+ ):
825
+ | keyof ReturnType<typeof opportunityMetrics>
826
+ | "fee"
827
+ | "volume"
828
+ | "active_tvl"
829
+ | "tvl" {
830
+ if (sort === "flow-inactive" || sort === "flow") return "flowInactive";
831
+ if (sort === "inactive" || sort === "inactive-pct") return "inactivePct";
832
+ if (sort === "fee-active" || sort === "fee-active-tvl") return "feeActive";
833
+ if (sort === "volume-active" || sort === "vol-active") return "volumeActive";
834
+ if (sort === "fee" || sort === "fees") return "fee";
835
+ if (sort === "volume") return "volume";
836
+ if (sort === "active-tvl") return "active_tvl";
837
+ if (sort === "tvl" || sort === "total-tvl") return "tvl";
838
+ throw new Error(
839
+ "--sort must be flow-inactive, inactive, fee-active, volume-active, fee, volume, active-tvl, or tvl",
840
+ );
841
+ }
842
+
843
+ function opportunitySortValue(
844
+ row: AnyRow,
845
+ field: ReturnType<typeof opportunitySortField>,
846
+ ): number | null {
847
+ if (
848
+ field === "fee" ||
849
+ field === "volume" ||
850
+ field === "active_tvl" ||
851
+ field === "tvl"
852
+ ) {
853
+ return finiteMetric(row[field]);
854
+ }
855
+ return finiteMetric(opportunityMetrics(row)[field]);
856
+ }
857
+
858
+ function sortOpportunityRows(
859
+ rows: AnyRow[],
860
+ sort: string,
861
+ direction: string,
862
+ ): AnyRow[] {
863
+ const field = opportunitySortField(sort);
864
+ const factor = direction === "asc" ? 1 : -1;
865
+ return [...rows].sort((a, b) => {
866
+ const av = opportunitySortValue(a, field);
867
+ const bv = opportunitySortValue(b, field);
868
+ if (av == null && bv == null) return 0;
869
+ if (av == null) return 1;
870
+ if (bv == null) return -1;
871
+ return (av - bv) * factor;
872
+ });
873
+ }
874
+
875
+ function opportunityTable(rows: AnyRow[], timeframe: MeteoraTimeframe): string {
876
+ if (!rows.length) return "No Meteora opportunities matched.\n";
877
+ const feeLabel = `FEE ${timeframe.toUpperCase()}`;
878
+ const volumeLabel = `VOL ${timeframe.toUpperCase()}`;
879
+ const header = [
880
+ pad("NAME", 20),
881
+ pad("INACT", 8, "right"),
882
+ pad("FLOW*INACT", 11, "right"),
883
+ pad("VOL/ACTIVE", 10, "right"),
884
+ pad("FEE/ACTIVE", 10, "right"),
885
+ pad(feeLabel, 10, "right"),
886
+ pad("ACTIVE", 10, "right"),
887
+ pad("TOTAL", 10, "right"),
888
+ pad(volumeLabel, 10, "right"),
889
+ pad("BIN", 4, "right"),
890
+ "POOL",
891
+ ].join(" ");
892
+ const body = rows.map((row) => {
893
+ const m = opportunityMetrics(row);
894
+ return [
895
+ pad(String(row.name ?? "-"), 20),
896
+ pad(metricPercent(m.inactivePct), 8, "right"),
897
+ pad(multiple(m.flowInactive), 11, "right"),
898
+ pad(multiple(m.volumeActive), 10, "right"),
899
+ pad(percentRatio(m.feeActive), 10, "right"),
900
+ pad(usd(row.fee), 10, "right"),
901
+ pad(usd(row.active_tvl), 10, "right"),
902
+ pad(usd(row.tvl), 10, "right"),
903
+ pad(usd(row.volume), 10, "right"),
904
+ pad(row.dlmm_params?.bin_step ?? row.bin_step ?? "-", 4, "right"),
905
+ discoveryAddress(row),
906
+ ].join(" ");
907
+ });
908
+ return [header, ...body].join("\n") + "\n";
909
+ }
910
+
911
+ function tokenMintFromRow(row: AnyRow, side: "x" | "y"): string | null {
912
+ const token = side === "x" ? row.token_x : row.token_y;
913
+ const mint = token?.address ?? token?.mint ?? row[`mint_${side}`] ?? null;
914
+ return mint == null ? null : String(mint);
915
+ }
916
+
917
+ function tokenSymbolFromRow(row: AnyRow, side: "x" | "y"): string | null {
918
+ const token = side === "x" ? row.token_x : row.token_y;
919
+ const symbol = token?.symbol ?? row[`mint_${side}_symbol`] ?? null;
920
+ return symbol == null ? null : String(symbol);
921
+ }
922
+
923
+ function matchesTokenQuery(row: AnyRow, query: string): boolean {
924
+ const q = query.trim();
925
+ if (!q) return false;
926
+ if (tokenMintFromRow(row, "x") === q || tokenMintFromRow(row, "y") === q)
927
+ return true;
928
+ const upper = q.toUpperCase();
929
+ return (
930
+ tokenSymbolFromRow(row, "x")?.toUpperCase() === upper ||
931
+ tokenSymbolFromRow(row, "y")?.toUpperCase() === upper
932
+ );
933
+ }
934
+
935
+ async function resolvePositionPool(
936
+ slrd: Solard,
937
+ position: string,
938
+ flags: Flags,
939
+ ): Promise<{ pool: string; walletAddress?: string }> {
940
+ const explicit = flag(flags, "from-pool") ?? flag(flags, "pool");
941
+ const walletRef = flag(flags, "wallet");
942
+ const walletAddress = walletRef
943
+ ? publicWalletAddress(slrd, walletRef)
944
+ : undefined;
945
+ if (explicit) return { pool: explicit, walletAddress };
946
+ if (!walletAddress) {
947
+ throw new Error(
948
+ "Position command needs --wallet <stored-wallet|public-address> to resolve its pool, or pass --pool <pool-address> explicitly.",
949
+ );
950
+ }
951
+ return {
952
+ pool: await slrd.meteora.findPoolForPosition(position, walletAddress),
953
+ walletAddress,
954
+ };
955
+ }
956
+
957
+ function positiveDelta(after: bigint, before: bigint): bigint {
958
+ return after > before ? after - before : 0n;
959
+ }
960
+
961
+ function applyHaircutRaw(amount: bigint, haircutBps: number): bigint {
962
+ const keepBps = BigInt(Math.max(0, 10_000 - haircutBps));
963
+ return (amount * keepBps) / 10_000n;
964
+ }
965
+
966
+ async function snapshotWalletMints(
967
+ slrd: Solard,
968
+ walletRef: string,
969
+ mints: string[],
970
+ ): Promise<{ byMint: Map<string, bigint>; solLamports: bigint }> {
971
+ const wanted = new Set(mints);
972
+ const byMint = new Map<string, bigint>();
973
+ for (const mint of wanted) byMint.set(mint, 0n);
974
+ const accounts = await slrd.tokenAccounts(walletRef);
975
+ for (const account of accounts) {
976
+ if (!wanted.has(account.mint)) continue;
977
+ byMint.set(
978
+ account.mint,
979
+ (byMint.get(account.mint) ?? 0n) + BigInt(account.amountRaw),
980
+ );
981
+ }
982
+ const owner = slrd.resolveWallet(walletRef).address;
983
+ const solLamports = BigInt(
984
+ await slrd.connection().getBalance(owner, "confirmed"),
985
+ );
986
+ return { byMint, solLamports };
987
+ }
988
+
989
+ function migrateRange(
990
+ activeBin: number,
991
+ width: number,
992
+ amountXRaw: bigint,
993
+ amountYRaw: bigint,
994
+ ): {
995
+ minBinId: number;
996
+ maxBinId: number;
997
+ inventory: "x-only" | "y-only" | "mixed";
998
+ } {
999
+ const distance = Math.max(0, width - 1);
1000
+ if (amountXRaw > 0n && amountYRaw === 0n) {
1001
+ return {
1002
+ minBinId: activeBin,
1003
+ maxBinId: activeBin + distance,
1004
+ inventory: "x-only",
1005
+ };
1006
+ }
1007
+ if (amountYRaw > 0n && amountXRaw === 0n) {
1008
+ return {
1009
+ minBinId: activeBin - distance,
1010
+ maxBinId: activeBin,
1011
+ inventory: "y-only",
1012
+ };
1013
+ }
1014
+ const below = Math.floor(distance / 2);
1015
+ return {
1016
+ minBinId: activeBin - below,
1017
+ maxBinId: activeBin + (distance - below),
1018
+ inventory: "mixed",
1019
+ };
1020
+ }
1021
+
1022
+ function migratePlacementValid(
1023
+ activeBin: number,
1024
+ range: {
1025
+ minBinId: number;
1026
+ maxBinId: number;
1027
+ inventory: "x-only" | "y-only" | "mixed";
1028
+ },
1029
+ ): boolean {
1030
+ if (range.inventory === "x-only") return range.minBinId >= activeBin;
1031
+ if (range.inventory === "y-only") return range.maxBinId <= activeBin;
1032
+ return activeBin >= range.minBinId && activeBin <= range.maxBinId;
1033
+ }
1034
+
1035
+ function migrationHuman(result: AnyRow): string {
1036
+ const lines: string[] = [];
1037
+ const moving = result.operation === "move";
1038
+ if (!result.live) {
1039
+ lines.push(`${moving ? "Move" : "Migrate"} ${String(result.position)}`);
1040
+ if (moving) {
1041
+ lines.push(`Pool: ${result.fromPool}`);
1042
+ lines.push(
1043
+ `Range: ${result.sourceRange.minBinId}..${result.sourceRange.maxBinId} -> ${result.targetRange.minBinId}..${result.targetRange.maxBinId}`,
1044
+ );
1045
+ lines.push(
1046
+ `Active: ${result.targetActiveBin} width=${result.sourceRange.width} bins`,
1047
+ );
1048
+ } else {
1049
+ lines.push(
1050
+ `From: ${result.fromPool} range ${result.sourceRange.minBinId}..${result.sourceRange.maxBinId}`,
1051
+ );
1052
+ lines.push(
1053
+ `To: ${result.toPool} active ${result.targetActiveBin} target ${result.targetRange.minBinId}..${result.targetRange.maxBinId}`,
1054
+ );
1055
+ }
1056
+ lines.push(
1057
+ `Inventory now: X=${result.sourceInventory.amountXRaw} Y=${result.sourceInventory.amountYRaw} (+ unclaimed fees on close)`,
1058
+ );
1059
+ lines.push(`Placement: ${result.targetRange.inventory}`);
1060
+ lines.push(
1061
+ moving
1062
+ ? `Dry run. Add --live to close this position and reopen only its actual close proceeds around the current bin in the same pool.`
1063
+ : `Dry run. Add --live to close the source position and reopen only its actual close proceeds in the destination pool.`,
1064
+ );
1065
+ return lines.join("\n") + "\n";
1066
+ }
1067
+ lines.push(`${moving ? "Moved" : "Migrated"} ${String(result.oldPosition)}`);
1068
+ if (moving) {
1069
+ lines.push(`Pool: ${result.fromPool}`);
1070
+ } else {
1071
+ lines.push(`From: ${result.fromPool}`);
1072
+ lines.push(`To: ${result.toPool}`);
1073
+ }
1074
+ lines.push(`New position: ${result.newPosition}`);
1075
+ lines.push(
1076
+ `Range: ${result.range.minBinId}..${result.range.maxBinId} active-before-send=${result.activeBinBeforeSend}`,
1077
+ );
1078
+ lines.push(
1079
+ `Moved: X=${result.moved.amountXRaw} Y=${result.moved.amountYRaw} haircut=${result.moved.haircutBps}bps`,
1080
+ );
1081
+ lines.push(
1082
+ `Verified: active=${result.verification.activeBin} range=${result.verification.lowerBin}..${result.verification.upperBin} inRange=${String(result.verification.inRange)}`,
1083
+ );
1084
+ lines.push(`Close tx: ${result.close.signatures.join(", ")}`);
1085
+ lines.push(`Open tx: ${result.open.signatures.join(", ")}`);
1086
+ return lines.join("\n") + "\n";
1087
+ }
1088
+
1089
+ async function migratePosition(
1090
+ slrd: Solard,
1091
+ position: string,
1092
+ flags: Flags,
1093
+ options: { allowSamePool?: boolean; operation?: "migrate" | "move" } = {},
1094
+ ): Promise<AnyRow> {
1095
+ const wallet = requiredFlag(flags, "wallet");
1096
+ const toPool = requiredFlag(flags, "to-pool");
1097
+ const operation = options.operation ?? "migrate";
1098
+ const walletAddress = publicWalletAddress(slrd, wallet);
1099
+ const explicitFromPool = flag(flags, "from-pool") ?? flag(flags, "pool");
1100
+ let fromPool: string;
1101
+ if (explicitFromPool) {
1102
+ fromPool = explicitFromPool;
1103
+ } else {
1104
+ try {
1105
+ fromPool = await slrd.meteora.findPoolForPosition(
1106
+ position,
1107
+ walletAddress,
1108
+ );
1109
+ } catch (error: any) {
1110
+ const message = error instanceof Error ? error.message : String(error);
1111
+ throw new Error(
1112
+ `${message}. Wallet-wide Meteora position enumeration can lag after rapid close/open activity; if you know the source pool, retry with --from-pool <pool-address>.`,
1113
+ );
1114
+ }
1115
+ }
1116
+ if (fromPool === toPool && !options.allowSamePool) {
1117
+ throw new Error(
1118
+ "--to-pool must differ from the source pool; use `slrd meteora move <position> --wallet <wallet>` for a same-pool range move",
1119
+ );
1120
+ }
1121
+
1122
+ const [fresh, targetState] = await Promise.all([
1123
+ slrd.meteora.getPosition(fromPool, position),
1124
+ slrd.meteora.getPoolState(toPool, true),
1125
+ ]);
1126
+ if (fresh.owner && String(fresh.owner) !== walletAddress) {
1127
+ throw new Error(
1128
+ `Meteora position ${position} belongs to ${fresh.owner}, not wallet ${walletAddress}.`,
1129
+ );
1130
+ }
1131
+ const sourceX = String(fresh.tokenX?.mint ?? "");
1132
+ const sourceY = String(fresh.tokenY?.mint ?? "");
1133
+ const targetX = String(targetState.tokenX?.mint ?? "");
1134
+ const targetY = String(targetState.tokenY?.mint ?? "");
1135
+ if (!sourceX || !sourceY || !targetX || !targetY)
1136
+ throw new Error("Could not determine source/target pool token mints");
1137
+ if (
1138
+ new Set([sourceX, sourceY]).size !== 2 ||
1139
+ new Set([targetX, targetY]).size !== 2 ||
1140
+ ![sourceX, sourceY].includes(targetX) ||
1141
+ ![sourceX, sourceY].includes(targetY)
1142
+ ) {
1143
+ throw new Error(
1144
+ "Meteora migrate currently supports no-swap migration only between pools with the same two token mints",
1145
+ );
1146
+ }
1147
+
1148
+ const oldMin = Number(fresh.lowerBin);
1149
+ const oldMax = Number(fresh.upperBin);
1150
+ if (!Number.isInteger(oldMin) || !Number.isInteger(oldMax))
1151
+ throw new Error("Could not determine source position bin range");
1152
+ const width = positiveIntegerFlag(flags, "bins", oldMax - oldMin + 1)!;
1153
+ const haircutBps = Math.max(
1154
+ 0,
1155
+ Math.min(2_000, integerFlag(flags, "haircut-bps", 100)!),
1156
+ );
1157
+
1158
+ const sourceAmounts = new Map<string, bigint>([
1159
+ [sourceX, BigInt(fresh.totalXRaw ?? "0")],
1160
+ [sourceY, BigInt(fresh.totalYRaw ?? "0")],
1161
+ ]);
1162
+ const estimatedTargetX = sourceAmounts.get(targetX) ?? 0n;
1163
+ const estimatedTargetY = sourceAmounts.get(targetY) ?? 0n;
1164
+ const targetRange = migrateRange(
1165
+ targetState.activeBin.binId,
1166
+ width,
1167
+ estimatedTargetX,
1168
+ estimatedTargetY,
1169
+ );
1170
+
1171
+ if (!flags.has("live")) {
1172
+ return {
1173
+ operation,
1174
+ live: false,
1175
+ position,
1176
+ fromPool,
1177
+ toPool,
1178
+ sourceRange: { minBinId: oldMin, maxBinId: oldMax, width },
1179
+ sourceInventory: {
1180
+ amountXRaw: fresh.totalXRaw,
1181
+ amountYRaw: fresh.totalYRaw,
1182
+ feeXRaw: fresh.feeXRaw,
1183
+ feeYRaw: fresh.feeYRaw,
1184
+ },
1185
+ targetActiveBin: targetState.activeBin.binId,
1186
+ targetRange,
1187
+ targetTokens: { x: targetX, y: targetY },
1188
+ note: "Plan uses current position inventory only. Live mode measures actual close-attributable wallet deltas, including claimed fees, before opening the destination position.",
1189
+ };
1190
+ }
1191
+
1192
+ const before = await snapshotWalletMints(slrd, wallet, [sourceX, sourceY]);
1193
+ const closePrepared = await slrd.meteora.buildClosePosition({
1194
+ wallet,
1195
+ pool: fromPool,
1196
+ position,
1197
+ });
1198
+ const close = await slrd.meteora.executePrepared(closePrepared, {
1199
+ live: true,
1200
+ simulate: !flags.has("skip-simulation"),
1201
+ skipPreflight: flags.has("skip-preflight"),
1202
+ commitment: (flag(flags, "commitment") as any) ?? "confirmed",
1203
+ maxRetries: integerFlag(flags, "max-retries"),
1204
+ });
1205
+ const after = await snapshotWalletMints(slrd, wallet, [sourceX, sourceY]);
1206
+ const byMint = new Map<string, bigint>();
1207
+ for (const mint of [sourceX, sourceY]) {
1208
+ byMint.set(
1209
+ mint,
1210
+ positiveDelta(
1211
+ after.byMint.get(mint) ?? 0n,
1212
+ before.byMint.get(mint) ?? 0n,
1213
+ ),
1214
+ );
1215
+ }
1216
+ const nativeSolDelta = positiveDelta(after.solLamports, before.solLamports);
1217
+ if (
1218
+ sourceX === WSOL &&
1219
+ (byMint.get(sourceX) ?? 0n) === 0n &&
1220
+ nativeSolDelta > 0n
1221
+ )
1222
+ byMint.set(sourceX, nativeSolDelta);
1223
+ if (
1224
+ sourceY === WSOL &&
1225
+ (byMint.get(sourceY) ?? 0n) === 0n &&
1226
+ nativeSolDelta > 0n
1227
+ )
1228
+ byMint.set(sourceY, nativeSolDelta);
1229
+
1230
+ let amountXRaw = applyHaircutRaw(byMint.get(targetX) ?? 0n, haircutBps);
1231
+ let amountYRaw = applyHaircutRaw(byMint.get(targetY) ?? 0n, haircutBps);
1232
+ if (amountXRaw === 0n && amountYRaw === 0n) {
1233
+ throw new Error(
1234
+ `Source position closed, but no positive close-attributable proceeds were observed. Funds remain in the wallet; destination position was not opened.`,
1235
+ );
1236
+ }
1237
+
1238
+ const attempts = Math.max(
1239
+ 1,
1240
+ Math.min(10, positiveIntegerFlag(flags, "pre-open-retries", 3)!),
1241
+ );
1242
+ let prepared: MeteoraPreparedTransactions | null = null;
1243
+ let finalRange: ReturnType<typeof migrateRange> | null = null;
1244
+ let activeBinBeforeSend: number | null = null;
1245
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1246
+ const active = await slrd.meteora.getActiveBin(toPool, true);
1247
+ const range = migrateRange(active.binId, width, amountXRaw, amountYRaw);
1248
+ prepared = await slrd.meteora.buildOpenPosition({
1249
+ wallet,
1250
+ pool: toPool,
1251
+ strategy: strategy(flags),
1252
+ amountXRaw: amountXRaw.toString(),
1253
+ amountYRaw: amountYRaw.toString(),
1254
+ minBinId: range.minBinId,
1255
+ maxBinId: range.maxBinId,
1256
+ slippageBps: integerFlag(flags, "slippage-bps", 100),
1257
+ });
1258
+ const check = await slrd.meteora.getActiveBin(toPool, true);
1259
+ activeBinBeforeSend = check.binId;
1260
+ if (migratePlacementValid(check.binId, range)) {
1261
+ finalRange = range;
1262
+ break;
1263
+ }
1264
+ prepared = null;
1265
+ }
1266
+ if (!prepared || !finalRange || activeBinBeforeSend == null) {
1267
+ throw new Error(
1268
+ `Source position was closed, but the destination active bin moved across the proposed ${width}-bin range during ${attempts} build attempts. Proceeds remain in the wallet.`,
1269
+ );
1270
+ }
1271
+
1272
+ const open = await slrd.meteora.executePrepared(prepared, {
1273
+ live: true,
1274
+ simulate: !flags.has("skip-simulation"),
1275
+ skipPreflight: flags.has("skip-preflight"),
1276
+ commitment: (flag(flags, "commitment") as any) ?? "confirmed",
1277
+ maxRetries: integerFlag(flags, "max-retries"),
1278
+ });
1279
+ const verified = await slrd.meteora.getPosition(toPool, prepared.position!);
1280
+ return {
1281
+ operation,
1282
+ live: true,
1283
+ oldPosition: position,
1284
+ fromPool,
1285
+ toPool,
1286
+ close,
1287
+ newPosition: prepared.position,
1288
+ open,
1289
+ range: finalRange,
1290
+ activeBinBeforeSend,
1291
+ moved: {
1292
+ amountXRaw: amountXRaw.toString(),
1293
+ amountYRaw: amountYRaw.toString(),
1294
+ haircutBps,
1295
+ },
1296
+ verification: {
1297
+ activeBin: verified.activeBin,
1298
+ lowerBin: verified.lowerBin,
1299
+ upperBin: verified.upperBin,
1300
+ inRange: verified.inRange,
1301
+ },
1302
+ };
1303
+ }
1304
+
1305
+ async function movePosition(
1306
+ slrd: Solard,
1307
+ position: string,
1308
+ flags: Flags,
1309
+ ): Promise<AnyRow> {
1310
+ const wallet = requiredFlag(flags, "wallet");
1311
+ const resolved = await resolvePositionPool(slrd, position, flags);
1312
+ const moveFlags = new Map(flags);
1313
+ moveFlags.set("wallet", wallet);
1314
+ moveFlags.set("from-pool", resolved.pool);
1315
+ moveFlags.set("to-pool", resolved.pool);
1316
+ return migratePosition(slrd, position, moveFlags, {
1317
+ allowSamePool: true,
1318
+ operation: "move",
1319
+ });
1320
+ }
1321
+
1322
+ function rangeArgs(flags: Flags): {
1323
+ minBinId?: number;
1324
+ maxBinId?: number;
1325
+ binsBelow?: number;
1326
+ binsAbove?: number;
1327
+ downsidePct?: number;
1328
+ upsidePct?: number;
1329
+ } {
1330
+ return {
1331
+ minBinId: integerFlag(flags, "min-bin"),
1332
+ maxBinId: integerFlag(flags, "max-bin"),
1333
+ binsBelow: integerFlag(flags, "bins-below"),
1334
+ binsAbove: integerFlag(flags, "bins-above"),
1335
+ downsidePct: numberFlag(flags, "downside-pct"),
1336
+ upsidePct: numberFlag(flags, "upside-pct"),
1337
+ };
1338
+ }
1339
+
1340
+ async function openPosition(
1341
+ slrd: Solard,
1342
+ pool: string,
1343
+ flags: Flags,
1344
+ ): Promise<unknown> {
1345
+ const wallet = requiredFlag(flags, "wallet");
1346
+ const solAmount = flag(flags, "sol");
1347
+ const amountX = flag(flags, "x") ?? flag(flags, "amount-x");
1348
+ const amountY = flag(flags, "y") ?? flag(flags, "amount-y");
1349
+ const suppliedAmounts = [solAmount, amountX, amountY].filter(
1350
+ (value) => value != null,
1351
+ ).length;
1352
+ if (suppliedAmounts !== 1) {
1353
+ throw new Error(
1354
+ "Supply exactly one of --sol <amount>, --x <amount>, or --y <amount>",
1355
+ );
1356
+ }
1357
+
1358
+ const range = rangeArgs(flags);
1359
+ const shorthandBins = positiveIntegerFlag(flags, "bins");
1360
+ let x = amountX;
1361
+ let y = amountY;
1362
+
1363
+ if (solAmount != null) {
1364
+ const state = await slrd.meteora.getPoolState(pool, true);
1365
+ const xIsSol = state.tokenX.mint === WSOL;
1366
+ const yIsSol = state.tokenY.mint === WSOL;
1367
+ if (!xIsSol && !yIsSol) {
1368
+ throw new Error(
1369
+ "--sol can only be used when one side of the DLMM pool is wrapped SOL; use --x or --y for other pairs.",
1370
+ );
1371
+ }
1372
+ if (xIsSol) x = solAmount;
1373
+ else y = solAmount;
1374
+
1375
+ if (shorthandBins != null) {
1376
+ const explicitRange = Object.values(range).some((value) => value != null);
1377
+ if (explicitRange) {
1378
+ throw new Error(
1379
+ "Use either --bins shorthand or explicit range flags, not both",
1380
+ );
1381
+ }
1382
+ // --bins N means N total bins including the active-bin boundary.
1383
+ // The lower-level binsBelow/binsAbove fields are distances from active,
1384
+ // so an N-bin one-sided range uses a distance of N - 1.
1385
+ const shorthandDistance = shorthandBins - 1;
1386
+ if (xIsSol) {
1387
+ range.binsBelow = 0;
1388
+ range.binsAbove = shorthandDistance;
1389
+ } else {
1390
+ range.binsBelow = shorthandDistance;
1391
+ range.binsAbove = 0;
1392
+ }
1393
+ }
1394
+ } else if (shorthandBins != null) {
1395
+ throw new Error(
1396
+ "--bins shorthand is only available with --sol; use --bins-below/--bins-above otherwise",
1397
+ );
1398
+ }
1399
+
1400
+ if (!Object.values(range).some((value) => value != null)) {
1401
+ throw new Error(
1402
+ "A DLMM range is required. Use --bins N with --sol, or explicit --bins-below/--bins-above, --min-bin/--max-bin, or --downside-pct/--upside-pct.",
1403
+ );
1404
+ }
1405
+
1406
+ const prepared = await slrd.meteora.buildOpenPosition({
1407
+ wallet,
1408
+ pool,
1409
+ strategy: strategy(flags),
1410
+ amountX: x,
1411
+ amountY: y,
1412
+ ...range,
1413
+ slippageBps: integerFlag(flags, "slippage-bps", 100),
1414
+ });
1415
+ return await finishWrite(slrd, prepared, flags);
1416
+ }
1417
+
1418
+ async function addLiquidity(
1419
+ slrd: Solard,
1420
+ position: string,
1421
+ flags: Flags,
1422
+ ): Promise<unknown> {
1423
+ const wallet = requiredFlag(flags, "wallet");
1424
+ const { pool } = await resolvePositionPool(slrd, position, flags);
1425
+ const solAmount = flag(flags, "sol");
1426
+ let amountX = flag(flags, "x") ?? flag(flags, "amount-x");
1427
+ let amountY = flag(flags, "y") ?? flag(flags, "amount-y");
1428
+ const supplied = [solAmount, amountX, amountY].filter(
1429
+ (value) => value != null,
1430
+ ).length;
1431
+ if (supplied !== 1) {
1432
+ throw new Error(
1433
+ "Supply exactly one of --sol <amount>, --x <amount>, or --y <amount>",
1434
+ );
1435
+ }
1436
+ if (solAmount != null) {
1437
+ const state = await slrd.meteora.getPoolState(pool, true);
1438
+ if (state.tokenX.mint === WSOL) amountX = solAmount;
1439
+ else if (state.tokenY.mint === WSOL) amountY = solAmount;
1440
+ else throw new Error("--sol requires a pool containing wrapped SOL");
1441
+ }
1442
+ const existing = await slrd.meteora.getPosition(pool, position);
1443
+ const prepared = await slrd.meteora.buildAddLiquidity({
1444
+ wallet,
1445
+ pool,
1446
+ position,
1447
+ strategy: strategy(flags),
1448
+ amountX,
1449
+ amountY,
1450
+ minBinId: integerFlag(flags, "min-bin", existing.lowerBin ?? undefined),
1451
+ maxBinId: integerFlag(flags, "max-bin", existing.upperBin ?? undefined),
1452
+ slippageBps: integerFlag(flags, "slippage-bps", 100),
1453
+ });
1454
+ return await finishWrite(slrd, prepared, flags);
1455
+ }
1456
+
1457
+ function exactDirection(flags: Flags): {
1458
+ kind: "in" | "out";
1459
+ swapForY: boolean;
1460
+ amount: string;
1461
+ } {
1462
+ const choices = [
1463
+ ["in-x", "in", true],
1464
+ ["in-y", "in", false],
1465
+ ["out-y", "out", true],
1466
+ ["out-x", "out", false],
1467
+ ] as const;
1468
+ const selected = choices.filter(([key]) => flag(flags, key) != null);
1469
+ if (selected.length !== 1) {
1470
+ throw new Error(
1471
+ "Supply exactly one of --in-x, --in-y, --out-x, or --out-y",
1472
+ );
1473
+ }
1474
+ const [key, kind, swapForY] = selected[0]!;
1475
+ return { kind, swapForY, amount: flag(flags, key)! };
1476
+ }
1477
+
1478
+ function closeAllHuman(result: AnyRow): string {
1479
+ const positions = Array.isArray(result.positions) ? result.positions : [];
1480
+ const lines = [
1481
+ `Pool: ${String(result.pool ?? "")}`,
1482
+ `Wallet: ${String(result.wallet ?? "")}`,
1483
+ `Positions: ${positions.length}`,
1484
+ "",
1485
+ ];
1486
+ if (!positions.length) {
1487
+ lines.push("No open positions in this pool.");
1488
+ return lines.join("\n") + "\n";
1489
+ }
1490
+ lines.push("POSITION\tRANGE\tINV X\tINV Y");
1491
+ for (const row of positions) {
1492
+ lines.push(
1493
+ [
1494
+ String(row.position ?? ""),
1495
+ `${String(row.lowerBin ?? "-")}..${String(row.upperBin ?? "-")}`,
1496
+ String(row.totalXRaw ?? "0"),
1497
+ String(row.totalYRaw ?? "0"),
1498
+ ].join("\t"),
1499
+ );
1500
+ }
1501
+ if (result.live === true) {
1502
+ lines.push("", `Closed: ${Number(result.closed ?? 0)}/${positions.length}`);
1503
+ const failed = Array.isArray(result.failed) ? result.failed : [];
1504
+ if (failed.length) {
1505
+ lines.push("Failures:");
1506
+ for (const row of failed)
1507
+ lines.push(
1508
+ ` ${String(row.position ?? "")}: ${String(row.error ?? "unknown error")}`,
1509
+ );
1510
+ }
1511
+ } else {
1512
+ lines.push(
1513
+ "",
1514
+ "Dry run only. Add --live to close every live position listed above.",
1515
+ );
1516
+ }
1517
+ return lines.join("\n") + "\n";
1518
+ }
1519
+
1520
+ export async function handleMeteoraCommand(args: {
1521
+ slrd: Solard;
1522
+ values: string[];
1523
+ flags: Flags;
1524
+ emit: Emit;
1525
+ }): Promise<void> {
1526
+ const { slrd, values, flags, emit } = args;
1527
+ const action = values[0] ?? "help";
1528
+
1529
+ if (action === "help") {
1530
+ emit(
1531
+ `Meteora DLMM\n\n` +
1532
+ `Read\n` +
1533
+ ` slrd meteora discover [--timeframe 30m] [--category all|top|new|trending] [--sort fee-active-tvl] [--limit 20] [--min-active-tvl N] [--min-volume N] [--page-size 100]\n` +
1534
+ ` slrd meteora opportunities [--timeframe 30m] [--sort flow-inactive|inactive|fee-active|volume-active] [--launchpad pump.fun] [--safe] [--risk-screen] [--min-inactive-pct N] [--min-volume-active N] [--limit 20]\n` +
1535
+ ` slrd meteora token-pools <mint|symbol> [--timeframe 30m] [--sort flow-inactive|fee-active|inactive] [--limit 20]\n` +
1536
+ ` slrd meteora pools [--timeframe 30m] [--sort fee-tvl|volume|tvl] [--limit 20] [--min-tvl N] [--min-volume N]\n` +
1537
+ ` slrd meteora pool <pool> [--timeframe 30m]\n` +
1538
+ ` slrd meteora candles <pool> [--timeframe 5m] [--start-time unix] [--end-time unix]\n` +
1539
+ ` slrd meteora active-bin <pool>\n` +
1540
+ ` slrd meteora positions --wallet <wallet|address> [--pool <pool>]\n` +
1541
+ ` slrd meteora position <position> (--wallet <wallet|address> | --pool <pool>)\n` +
1542
+ ` slrd meteora portfolio --wallet <wallet|address> [--open]\n` +
1543
+ ` slrd meteora history <position>\n` +
1544
+ ` slrd meteora stats [--series protocol-fees|trading-fees|volume]\n` +
1545
+ ` slrd meteora quote <pool> (--in-x N|--in-y N|--out-x N|--out-y N) [--slippage-bps 100]\n\n` +
1546
+ `Positions / swaps (prepare-only unless --live)\n` +
1547
+ ` slrd meteora open|create <pool> --wallet <wallet> --sol N --bins 40 [--strategy spot] [--live]\n` +
1548
+ ` slrd meteora add <position> --wallet <wallet> --sol N [--pool <pool>] [--live]\n` +
1549
+ ` slrd meteora remove <position> --wallet <wallet> [--bps 10000] [--pool <pool>] [--live]\n` +
1550
+ ` slrd meteora close <position> --wallet <wallet> [--pool <pool>] [--live]\n` +
1551
+ ` slrd meteora close-all <pool> --wallet <wallet> [--live]\n` +
1552
+ ` slrd meteora move <position> --wallet <wallet> [--pool <pool>] [--bins N] [--haircut-bps 100] [--live]\n` +
1553
+ ` slrd meteora migrate <position> --wallet <wallet> --to-pool <pool> [--from-pool <source-pool>] [--bins N] [--haircut-bps 100] [--live]\n` +
1554
+ ` slrd meteora claim <position> --wallet <wallet> [--kind fees|rewards|all] [--pool <pool>] [--live]\n` +
1555
+ ` slrd meteora claim-all <pool> --wallet <wallet> [--kind fees|rewards|all] [--live]\n` +
1556
+ ` slrd meteora swap <pool> --wallet <wallet> (--in-x N|--in-y N|--out-x N|--out-y N) [--live]\n\n` +
1557
+ `--live still requires SOLARD_ENABLE_LIVE_TRADES=1. Live sends simulate first and keep preflight enabled unless explicitly skipped.\n`,
1558
+ );
1559
+ return;
1560
+ }
1561
+
1562
+ if (action === "discover") {
1563
+ const tf = timeframe(flags, "30m");
1564
+ const categoryFlag = flag(flags, "category");
1565
+ const category = categoryFlag === "all" ? undefined : categoryFlag;
1566
+ if (category && !["top", "new", "trending"].includes(category)) {
1567
+ throw new Error("--category must be all, top, new, or trending");
1568
+ }
1569
+
1570
+ const limit = Math.max(
1571
+ 1,
1572
+ Math.min(100, positiveIntegerFlag(flags, "limit", 20)!),
1573
+ );
1574
+ // Pool Discovery behaves as a ranked feed, not a paginated list: page=2 is
1575
+ // currently ignored. Request a wider single feed than the displayed limit
1576
+ // so local threshold checks still have headroom.
1577
+ const pageSize = Math.max(
1578
+ limit,
1579
+ Math.min(100, positiveIntegerFlag(flags, "page-size", 100)!),
1580
+ );
1581
+
1582
+ const sort = flag(flags, "sort") ?? "fee-active-tvl";
1583
+ const direction = (flag(flags, "direction") ?? "desc").toLowerCase();
1584
+ if (direction !== "asc" && direction !== "desc") {
1585
+ throw new Error("--direction must be asc or desc");
1586
+ }
1587
+ const field =
1588
+ sort === "fee-active-tvl"
1589
+ ? "fee_active_tvl_ratio"
1590
+ : sort === "volume"
1591
+ ? "volume"
1592
+ : sort === "fee"
1593
+ ? "fee"
1594
+ : sort === "active-tvl"
1595
+ ? "active_tvl"
1596
+ : sort === "tvl"
1597
+ ? "tvl"
1598
+ : null;
1599
+ if (!field) {
1600
+ throw new Error(
1601
+ "--sort for discover must be fee-active-tvl, fee, volume, active-tvl, or tvl",
1602
+ );
1603
+ }
1604
+
1605
+ const baseFilters: string[] = ["pool_type=dlmm"];
1606
+ const launchpad = flag(flags, "launchpad");
1607
+ if (launchpad) baseFilters.push(`base_token_launchpad=[${launchpad}]`);
1608
+ if (flags.has("safe")) {
1609
+ baseFilters.push("base_token_has_critical_warnings=false");
1610
+ baseFilters.push("quote_token_has_critical_warnings=false");
1611
+ baseFilters.push("base_token_has_high_single_ownership=false");
1612
+ }
1613
+ appendFilter(baseFilters, flag(flags, "filter"));
1614
+ const minTvl = numberFlag(flags, "min-tvl");
1615
+ const minActiveTvl = numberFlag(flags, "min-active-tvl");
1616
+ const minVolume = numberFlag(flags, "min-volume");
1617
+ if (minTvl != null) baseFilters.push(`tvl>=${minTvl}`);
1618
+ if (minVolume != null) baseFilters.push(`volume>=${minVolume}`);
1619
+
1620
+ let activeTvlFilterMode: "server" | "local-fallback" = "server";
1621
+ let requestFilters = [...baseFilters];
1622
+ if (minActiveTvl != null)
1623
+ requestFilters.push(`active_tvl>=${minActiveTvl}`);
1624
+ let appliedFilterBy = requestFilters.join("&&");
1625
+
1626
+ const fetchFeed = async () =>
1627
+ await slrd.meteora.discoverPools({
1628
+ pageSize,
1629
+ timeframe: tf,
1630
+ category: category as any,
1631
+ filterBy: appliedFilterBy,
1632
+ });
1633
+
1634
+ let feed: Awaited<ReturnType<typeof slrd.meteora.discoverPools>>;
1635
+ try {
1636
+ feed = await fetchFeed();
1637
+ } catch (error) {
1638
+ const message = error instanceof Error ? error.message : String(error);
1639
+ const filterRejected = /HTTP (?:400|422)\b/.test(message);
1640
+ if (minActiveTvl == null || !filterRejected) throw error;
1641
+ activeTvlFilterMode = "local-fallback";
1642
+ appliedFilterBy = baseFilters.join("&&");
1643
+ feed = await fetchFeed();
1644
+ }
1645
+
1646
+ let rows = [...(feed.pools as AnyRow[])];
1647
+ if (minActiveTvl != null) {
1648
+ rows = rows.filter((row) => {
1649
+ const activeTvl = finiteMetric(row.active_tvl);
1650
+ return activeTvl != null && activeTvl >= minActiveTvl;
1651
+ });
1652
+ }
1653
+ if (minVolume != null) {
1654
+ rows = rows.filter((row) => {
1655
+ const volume = finiteMetric(row.volume);
1656
+ return volume != null && volume >= minVolume;
1657
+ });
1658
+ }
1659
+ if (minTvl != null) {
1660
+ rows = rows.filter((row) => {
1661
+ const tvl = finiteMetric(row.tvl);
1662
+ return tvl != null && tvl >= minTvl;
1663
+ });
1664
+ }
1665
+
1666
+ const factor = direction === "asc" ? 1 : -1;
1667
+ rows.sort((a, b) => {
1668
+ const av = finiteMetric(a[field]);
1669
+ const bv = finiteMetric(b[field]);
1670
+ if (av == null && bv == null) return 0;
1671
+ if (av == null) return 1;
1672
+ if (bv == null) return -1;
1673
+ return (av - bv) * factor;
1674
+ });
1675
+ rows = rows.slice(0, limit);
1676
+
1677
+ const result = {
1678
+ timeframe: tf,
1679
+ category: category ?? "all",
1680
+ sort: `${field}:${direction}`,
1681
+ rankingMode: "pool-discovery-ranked-feed",
1682
+ rankingScope: `single discovery feed (page_size=${pageSize}); Pool Discovery currently ignores page=2`,
1683
+ filterBy: appliedFilterBy,
1684
+ activeTvlFilterMode,
1685
+ reportedTotal: feed.total,
1686
+ feedSize: feed.pools.length,
1687
+ returned: rows.length,
1688
+ pools: rows,
1689
+ };
1690
+ emit(flags.has("json") ? json(result) + "\n" : discoveryTable(rows, tf));
1691
+ return;
1692
+ }
1693
+
1694
+ if (action === "opportunities") {
1695
+ const tf = timeframe(flags, "30m");
1696
+ const categoryFlag = flag(flags, "category");
1697
+ const category = categoryFlag === "all" ? undefined : categoryFlag;
1698
+ if (category && !["top", "new", "trending"].includes(category)) {
1699
+ throw new Error("--category must be all, top, new, or trending");
1700
+ }
1701
+ const limit = Math.max(
1702
+ 1,
1703
+ Math.min(100, positiveIntegerFlag(flags, "limit", 20)!),
1704
+ );
1705
+ const pageSize = Math.max(
1706
+ limit,
1707
+ Math.min(100, positiveIntegerFlag(flags, "page-size", 100)!),
1708
+ );
1709
+ const direction = (flag(flags, "direction") ?? "desc").toLowerCase();
1710
+ if (direction !== "asc" && direction !== "desc")
1711
+ throw new Error("--direction must be asc or desc");
1712
+ const sort = flag(flags, "sort") ?? "flow-inactive";
1713
+ opportunitySortField(sort); // validate early
1714
+
1715
+ const minTvl = numberFlag(flags, "min-tvl");
1716
+ const minActiveTvl = numberFlag(flags, "min-active-tvl");
1717
+ const minVolume = numberFlag(flags, "min-volume");
1718
+ const minFee = numberFlag(flags, "min-fee");
1719
+ const minInactivePct = numberFlag(flags, "min-inactive-pct");
1720
+ const minVolumeActive = numberFlag(flags, "min-volume-active");
1721
+ const minFeeActive = numberFlag(flags, "min-fee-active");
1722
+
1723
+ const filters = ["pool_type=dlmm"];
1724
+ const launchpad = flag(flags, "launchpad");
1725
+ if (launchpad) filters.push(`base_token_launchpad=[${launchpad}]`);
1726
+ if (flags.has("safe")) {
1727
+ // Meteora metadata gate only. Bundler/insider launch structure requires
1728
+ // --risk-screen, which checks each candidate directly with GMGN token security + holder data.
1729
+ filters.push("base_token_has_critical_warnings=false");
1730
+ filters.push("quote_token_has_critical_warnings=false");
1731
+ filters.push("base_token_has_high_single_ownership=false");
1732
+ }
1733
+ const riskScreen = flags.has("risk-screen");
1734
+ if (riskScreen && !launchpad)
1735
+ filters.push("base_token_launchpad=[pump.fun]");
1736
+ appendFilter(filters, flag(flags, "filter"));
1737
+ if (minTvl != null) filters.push(`tvl>=${minTvl}`);
1738
+ if (minVolume != null) filters.push(`volume>=${minVolume}`);
1739
+ // Derived metrics are deliberately filtered locally. This keeps the CLI
1740
+ // independent of undocumented Pool Discovery filter operators.
1741
+ const feed = await slrd.meteora.discoverPools({
1742
+ pageSize,
1743
+ timeframe: tf,
1744
+ category: category as any,
1745
+ filterBy: filters.join("&&"),
1746
+ });
1747
+ let rows = [...(feed.pools as AnyRow[])].filter((row) => {
1748
+ const active = finiteMetric(row.active_tvl);
1749
+ const total = finiteMetric(row.tvl);
1750
+ const volume = finiteMetric(row.volume);
1751
+ const fee = finiteMetric(row.fee);
1752
+ const m = opportunityMetrics(row);
1753
+ if (minActiveTvl != null && (active == null || active < minActiveTvl))
1754
+ return false;
1755
+ if (minTvl != null && (total == null || total < minTvl)) return false;
1756
+ if (minVolume != null && (volume == null || volume < minVolume))
1757
+ return false;
1758
+ if (minFee != null && (fee == null || fee < minFee)) return false;
1759
+ if (
1760
+ minInactivePct != null &&
1761
+ (m.inactivePct == null || m.inactivePct < minInactivePct)
1762
+ )
1763
+ return false;
1764
+ if (
1765
+ minVolumeActive != null &&
1766
+ (m.volumeActive == null || m.volumeActive < minVolumeActive)
1767
+ )
1768
+ return false;
1769
+ if (
1770
+ minFeeActive != null &&
1771
+ (m.feeActive == null || m.feeActive < minFeeActive)
1772
+ )
1773
+ return false;
1774
+ return true;
1775
+ });
1776
+ rows = sortOpportunityRows(rows, sort, direction);
1777
+ const risk = riskScreen
1778
+ ? await directRiskScreenRows(slrd, rows, flags, limit)
1779
+ : null;
1780
+ rows = risk ? risk.rows : rows.slice(0, limit);
1781
+
1782
+ const result = {
1783
+ timeframe: tf,
1784
+ category: category ?? "all",
1785
+ sort: `${sort}:${direction}`,
1786
+ formula: {
1787
+ inactivePct: "(tvl - active_tvl) / tvl * 100",
1788
+ volumeActive: "volume / active_tvl",
1789
+ flowInactive: "(volume / active_tvl) * (inactive_pct / 100)",
1790
+ },
1791
+ scope: `single Pool Discovery feed (page_size=${pageSize})`,
1792
+ filters: {
1793
+ minTvl: minTvl ?? null,
1794
+ minActiveTvl: minActiveTvl ?? null,
1795
+ minVolume: minVolume ?? null,
1796
+ minFee: minFee ?? null,
1797
+ minInactivePct: minInactivePct ?? null,
1798
+ minVolumeActive: minVolumeActive ?? null,
1799
+ minFeeActive: minFeeActive ?? null,
1800
+ meteoraMetadataSafe: flags.has("safe"),
1801
+ gmgnRiskScreen: risk
1802
+ ? {
1803
+ scanned: risk.scanned,
1804
+ passed: risk.passed,
1805
+ rejects: risk.rejects,
1806
+ ...risk.thresholds,
1807
+ }
1808
+ : null,
1809
+ },
1810
+ feedSize: feed.pools.length,
1811
+ returned: rows.length,
1812
+ pools: rows.map((row) => ({
1813
+ ...row,
1814
+ opportunity: opportunityMetrics(row),
1815
+ })),
1816
+ };
1817
+ if (flags.has("json")) {
1818
+ emit(json(result) + "\n");
1819
+ } else {
1820
+ const note = risk
1821
+ ? `Risk screen: direct GMGN token security + top holders; scanned ${risk.scanned}, passed ${risk.passed}; rejects: ${riskRejectSummary(risk.rejects)}.\n`
1822
+ : flags.has("safe")
1823
+ ? "NOTE: --safe is Meteora metadata-only. Add --risk-screen (GMGN_API_KEY required) for bundler/insider/rug screening.\n"
1824
+ : "";
1825
+ emit(note + opportunityTable(rows, tf));
1826
+ }
1827
+ return;
1828
+ }
1829
+
1830
+ if (action === "token-pools") {
1831
+ const query = values[1]?.trim();
1832
+ if (!query)
1833
+ throw new Error(
1834
+ "Usage: slrd meteora token-pools <mint|symbol> [--timeframe 30m]",
1835
+ );
1836
+ const tf = timeframe(flags, "30m");
1837
+ const limit = Math.max(
1838
+ 1,
1839
+ Math.min(50, positiveIntegerFlag(flags, "limit", 20)!),
1840
+ );
1841
+ const direction = (flag(flags, "direction") ?? "desc").toLowerCase();
1842
+ if (direction !== "asc" && direction !== "desc")
1843
+ throw new Error("--direction must be asc or desc");
1844
+ const sort = flag(flags, "sort") ?? "fee-active";
1845
+ opportunitySortField(sort);
1846
+
1847
+ const indexed = await slrd.meteora.searchPools(query, 100);
1848
+ const indexedRows: AnyRow[] = indexed.map((pool: any) => ({
1849
+ ...(pool.raw ?? {}),
1850
+ pool_address: pool.pool,
1851
+ address: pool.pool,
1852
+ name: pool.name,
1853
+ tvl: pool.tvl,
1854
+ bin_step: pool.binStep,
1855
+ token_x: {
1856
+ ...(pool.raw?.token_x ?? {}),
1857
+ address: pool.tokenX?.mint,
1858
+ symbol: pool.tokenX?.symbol,
1859
+ },
1860
+ token_y: {
1861
+ ...(pool.raw?.token_y ?? {}),
1862
+ address: pool.tokenY?.mint,
1863
+ symbol: pool.tokenY?.symbol,
1864
+ },
1865
+ }));
1866
+ const matches = indexedRows.filter((row) => matchesTokenQuery(row, query));
1867
+ if (!matches.length) {
1868
+ throw new Error(
1869
+ `No exact Meteora token pools matched ${query}. Use a token mint for unambiguous matching.`,
1870
+ );
1871
+ }
1872
+
1873
+ const enriched = await Promise.all(
1874
+ matches.slice(0, 50).map(async (row) => {
1875
+ const pool = discoveryAddress(row);
1876
+ const detail = await slrd.meteora
1877
+ .getPoolDetail(pool, tf)
1878
+ .catch(() => null);
1879
+ return detail
1880
+ ? {
1881
+ ...row,
1882
+ ...detail,
1883
+ pool_address: discoveryAddress(detail) || pool,
1884
+ name: detail.name ?? row.name,
1885
+ token_x: detail.token_x ?? row.token_x,
1886
+ token_y: detail.token_y ?? row.token_y,
1887
+ tvl: detail.tvl ?? row.tvl,
1888
+ bin_step: detail.bin_step ?? row.bin_step,
1889
+ }
1890
+ : row;
1891
+ }),
1892
+ );
1893
+
1894
+ const minTvl = numberFlag(flags, "min-tvl");
1895
+ const minActiveTvl = numberFlag(flags, "min-active-tvl");
1896
+ const minVolume = numberFlag(flags, "min-volume");
1897
+ const minFee = numberFlag(flags, "min-fee");
1898
+ const minInactivePct = numberFlag(flags, "min-inactive-pct");
1899
+ const minVolumeActive = numberFlag(flags, "min-volume-active");
1900
+ const minFeeActive = numberFlag(flags, "min-fee-active");
1901
+ const filtered = enriched.filter((row) => {
1902
+ const m = opportunityMetrics(row);
1903
+ if (minTvl != null && (finiteMetric(row.tvl) ?? -Infinity) < minTvl)
1904
+ return false;
1905
+ if (
1906
+ minActiveTvl != null &&
1907
+ (finiteMetric(row.active_tvl) ?? -Infinity) < minActiveTvl
1908
+ )
1909
+ return false;
1910
+ if (
1911
+ minVolume != null &&
1912
+ (finiteMetric(row.volume) ?? -Infinity) < minVolume
1913
+ )
1914
+ return false;
1915
+ if (minFee != null && (finiteMetric(row.fee) ?? -Infinity) < minFee)
1916
+ return false;
1917
+ if (
1918
+ minInactivePct != null &&
1919
+ (m.inactivePct ?? -Infinity) < minInactivePct
1920
+ )
1921
+ return false;
1922
+ if (
1923
+ minVolumeActive != null &&
1924
+ (m.volumeActive ?? -Infinity) < minVolumeActive
1925
+ )
1926
+ return false;
1927
+ if (minFeeActive != null && (m.feeActive ?? -Infinity) < minFeeActive)
1928
+ return false;
1929
+ return true;
1930
+ });
1931
+ const rows = sortOpportunityRows(filtered, sort, direction).slice(0, limit);
1932
+ const result = {
1933
+ query,
1934
+ timeframe: tf,
1935
+ sort: `${sort}:${direction}`,
1936
+ exactMatches: matches.length,
1937
+ returned: rows.length,
1938
+ pools: rows.map((row) => ({
1939
+ ...row,
1940
+ opportunity: opportunityMetrics(row),
1941
+ })),
1942
+ };
1943
+ emit(flags.has("json") ? json(result) + "\n" : opportunityTable(rows, tf));
1944
+ return;
1945
+ }
1946
+
1947
+ if (action === "pools") {
1948
+ const tf = timeframe(flags, "30m");
1949
+ const filters: string[] = [];
1950
+ appendFilter(filters, flag(flags, "filter"));
1951
+ const minTvl = numberFlag(flags, "min-tvl");
1952
+ const minVolume = numberFlag(flags, "min-volume");
1953
+ if (flags.has("min-active-tvl")) {
1954
+ throw new Error(
1955
+ "--min-active-tvl belongs to the Pool Discovery API. Use slrd meteora discover instead.",
1956
+ );
1957
+ }
1958
+ if (minTvl != null) filters.push(`tvl>=${minTvl}`);
1959
+ if (minVolume != null) filters.push(`volume_${tf}>=${minVolume}`);
1960
+ if (flags.has("exclude-blacklisted")) filters.push("is_blacklisted=false");
1961
+
1962
+ const payload = await slrd.meteora.listPools({
1963
+ page: positiveIntegerFlag(flags, "page", 1),
1964
+ pageSize: positiveIntegerFlag(flags, "limit", 20),
1965
+ query: flag(flags, "query"),
1966
+ sortBy: poolSort(flags, tf),
1967
+ filterBy: filters.length ? filters.join(" && ") : undefined,
1968
+ volumeTw: tf,
1969
+ feeTvlRatioTw: tf,
1970
+ });
1971
+ emit(flags.has("json") ? json(payload) + "\n" : poolTable(payload, tf));
1972
+ return;
1973
+ }
1974
+
1975
+ if (action === "pool") {
1976
+ const pool = values[1];
1977
+ if (!pool)
1978
+ throw new Error("Usage: slrd meteora pool <pool> [--timeframe 30m]");
1979
+ const tf = timeframe(flags, "30m");
1980
+ const [onChain, indexed, discovery] = await Promise.all([
1981
+ slrd.meteora.getPoolState(pool, true),
1982
+ slrd.meteora.getIndexedPool(pool).catch(() => null),
1983
+ slrd.meteora.getPoolDetail(pool, tf).catch(() => null),
1984
+ ]);
1985
+ const result = { pool, timeframe: tf, onChain, indexed, discovery };
1986
+ emit(flags.has("json") ? json(result) + "\n" : poolDetailText(result));
1987
+ return;
1988
+ }
1989
+
1990
+ if (action === "candles") {
1991
+ const pool = values[1];
1992
+ if (!pool)
1993
+ throw new Error("Usage: slrd meteora candles <pool> [--timeframe 5m]");
1994
+ const result = (await slrd.meteora.getPoolOhlcv(pool, {
1995
+ timeframe: timeframe(flags, "5m"),
1996
+ startTime: numberFlag(flags, "start-time"),
1997
+ endTime: numberFlag(flags, "end-time"),
1998
+ })) as AnyRow;
1999
+ emit(flags.has("json") ? json(result) + "\n" : candlesTable(result));
2000
+ return;
2001
+ }
2002
+
2003
+ if (action === "active-bin") {
2004
+ const pool = values[1];
2005
+ if (!pool) throw new Error("Usage: slrd meteora active-bin <pool>");
2006
+ const result = (await slrd.meteora.getActiveBin(pool, true)) as AnyRow;
2007
+ emit(flags.has("json") ? json(result) + "\n" : activeBinText(result));
2008
+ return;
2009
+ }
2010
+
2011
+ if (action === "positions") {
2012
+ const walletRef = requiredFlag(flags, "wallet");
2013
+ const wallet = publicWalletAddress(slrd, walletRef);
2014
+ const pool = flag(flags, "pool");
2015
+ const result = pool
2016
+ ? await slrd.meteora.getPoolPositions(pool, wallet)
2017
+ : await slrd.meteora.getWalletPositions(wallet);
2018
+ emit(
2019
+ flags.has("json")
2020
+ ? json(result) + "\n"
2021
+ : positionsTable(result as AnyRow),
2022
+ );
2023
+ return;
2024
+ }
2025
+
2026
+ if (action === "position") {
2027
+ const position = values[1];
2028
+ if (!position)
2029
+ throw new Error(
2030
+ "Usage: slrd meteora position <position> (--wallet <wallet|address> | --pool <pool>)",
2031
+ );
2032
+ const { pool } = await resolvePositionPool(slrd, position, flags);
2033
+ emit(json(await slrd.meteora.getPosition(pool, position)) + "\n");
2034
+ return;
2035
+ }
2036
+
2037
+ if (action === "portfolio") {
2038
+ const wallet = publicWalletAddress(slrd, requiredFlag(flags, "wallet"));
2039
+ const page = positiveIntegerFlag(flags, "page", 1);
2040
+ const pageSize = positiveIntegerFlag(flags, "limit", 50);
2041
+ const result = flags.has("open")
2042
+ ? await slrd.meteora.getOpenPortfolio({
2043
+ user: wallet,
2044
+ page,
2045
+ pageSize,
2046
+ sortDirection: (flag(flags, "direction") as any) ?? "desc",
2047
+ sortBy: flag(flags, "sort") as any,
2048
+ })
2049
+ : await slrd.meteora.getPortfolio({
2050
+ user: wallet,
2051
+ page,
2052
+ pageSize,
2053
+ daysBack: numberFlag(flags, "days-back"),
2054
+ });
2055
+ emit(
2056
+ flags.has("json") ? json(result) + "\n" : portfolioText(result as AnyRow),
2057
+ );
2058
+ return;
2059
+ }
2060
+
2061
+ if (action === "history") {
2062
+ const position = values[1];
2063
+ if (!position) throw new Error("Usage: slrd meteora history <position>");
2064
+ emit(
2065
+ json(
2066
+ await slrd.meteora.getPositionHistory(position, {
2067
+ eventType: flag(flags, "event-type") as any,
2068
+ orderDirection: flag(flags, "direction") as any,
2069
+ page: positiveIntegerFlag(flags, "page", 1),
2070
+ pageSize: positiveIntegerFlag(flags, "limit", 50),
2071
+ }),
2072
+ ) + "\n",
2073
+ );
2074
+ return;
2075
+ }
2076
+
2077
+ if (action === "stats") {
2078
+ const series = flag(flags, "series");
2079
+ const result =
2080
+ series === "protocol-fees"
2081
+ ? await slrd.meteora.getDailyProtocolFees()
2082
+ : series === "trading-fees"
2083
+ ? await slrd.meteora.getDailyTradingFees()
2084
+ : series === "volume"
2085
+ ? await slrd.meteora.getDailyVolume()
2086
+ : await slrd.meteora.getProtocolMetrics();
2087
+ if (
2088
+ series &&
2089
+ !["protocol-fees", "trading-fees", "volume"].includes(series)
2090
+ ) {
2091
+ throw new Error(
2092
+ "--series must be protocol-fees, trading-fees, or volume",
2093
+ );
2094
+ }
2095
+ emit(json(result) + "\n");
2096
+ return;
2097
+ }
2098
+
2099
+ if (action === "quote") {
2100
+ const pool = values[1];
2101
+ if (!pool)
2102
+ throw new Error(
2103
+ "Usage: slrd meteora quote <pool> (--in-x N|--in-y N|--out-x N|--out-y N)",
2104
+ );
2105
+ const direction = exactDirection(flags);
2106
+ const slippageBps = integerFlag(flags, "slippage-bps", 100);
2107
+ const result =
2108
+ direction.kind === "in"
2109
+ ? await slrd.meteora.quoteSwapExactIn({
2110
+ pool,
2111
+ swapForY: direction.swapForY,
2112
+ amountIn: direction.amount,
2113
+ slippageBps,
2114
+ allowPartialFill: flags.has("allow-partial-fill"),
2115
+ maxExtraBinArrays: integerFlag(flags, "max-extra-bin-arrays"),
2116
+ })
2117
+ : await slrd.meteora.quoteSwapExactOut({
2118
+ pool,
2119
+ swapForY: direction.swapForY,
2120
+ amountOut: direction.amount,
2121
+ slippageBps,
2122
+ maxExtraBinArrays: integerFlag(flags, "max-extra-bin-arrays"),
2123
+ });
2124
+ emit(json(result) + "\n");
2125
+ return;
2126
+ }
2127
+
2128
+ if (action === "open" || action === "create") {
2129
+ const pool = values[1];
2130
+ if (!pool)
2131
+ throw new Error(
2132
+ `Usage: slrd meteora ${action} <pool> --wallet <wallet> ...`,
2133
+ );
2134
+ emit(json(await openPosition(slrd, pool, flags)) + "\n");
2135
+ return;
2136
+ }
2137
+
2138
+ if (action === "add") {
2139
+ const position = values[1];
2140
+ if (!position)
2141
+ throw new Error(
2142
+ "Usage: slrd meteora add <position> --wallet <wallet> --sol N [--live]",
2143
+ );
2144
+ emit(json(await addLiquidity(slrd, position, flags)) + "\n");
2145
+ return;
2146
+ }
2147
+
2148
+ if (action === "remove") {
2149
+ const position = values[1];
2150
+ if (!position)
2151
+ throw new Error(
2152
+ "Usage: slrd meteora remove <position> --wallet <wallet> [--bps 10000] [--live]",
2153
+ );
2154
+ const wallet = requiredFlag(flags, "wallet");
2155
+ const { pool } = await resolvePositionPool(slrd, position, flags);
2156
+ const prepared = await slrd.meteora.buildRemoveLiquidity({
2157
+ wallet,
2158
+ pool,
2159
+ position,
2160
+ bps: integerFlag(flags, "bps", 10_000),
2161
+ fromBinId: integerFlag(flags, "from-bin"),
2162
+ toBinId: integerFlag(flags, "to-bin"),
2163
+ claimAndClose: flags.has("claim-and-close"),
2164
+ skipUnwrapSol: flags.has("skip-unwrap-sol"),
2165
+ });
2166
+ emit(json(await finishWrite(slrd, prepared, flags)) + "\n");
2167
+ return;
2168
+ }
2169
+
2170
+ if (
2171
+ action === "move" ||
2172
+ action === "reposition" ||
2173
+ action === "move-position"
2174
+ ) {
2175
+ const position = values[1];
2176
+ if (!position) {
2177
+ throw new Error(
2178
+ "Usage: slrd meteora move <position> --wallet <wallet> [--pool <pool>] [--bins N] [--live]",
2179
+ );
2180
+ }
2181
+ const result = await movePosition(slrd, position, flags);
2182
+ emit(flags.has("json") ? json(result) + "\n" : migrationHuman(result));
2183
+ return;
2184
+ }
2185
+
2186
+ if (action === "migrate") {
2187
+ const position = values[1];
2188
+ if (!position) {
2189
+ throw new Error(
2190
+ "Usage: slrd meteora migrate <position> --wallet <wallet> --to-pool <pool> [--from-pool <source-pool>] [--bins N] [--live]",
2191
+ );
2192
+ }
2193
+ const result = await migratePosition(slrd, position, flags);
2194
+ emit(flags.has("json") ? json(result) + "\n" : migrationHuman(result));
2195
+ return;
2196
+ }
2197
+
2198
+ if (action === "close-all") {
2199
+ const pool = values[1] ?? flag(flags, "pool");
2200
+ if (!pool)
2201
+ throw new Error(
2202
+ "Usage: slrd meteora close-all <pool> --wallet <wallet> [--live]",
2203
+ );
2204
+ const walletRef = requiredFlag(flags, "wallet");
2205
+ const walletAddress = publicWalletAddress(slrd, walletRef);
2206
+ const positions = await slrd.meteora.getPoolPositions(pool, walletAddress);
2207
+ const summaryPositions = positions.map((row: AnyRow) => ({
2208
+ position: String(row.position ?? ""),
2209
+ lowerBin: row.lowerBin ?? null,
2210
+ upperBin: row.upperBin ?? null,
2211
+ totalXRaw: String(row.totalXRaw ?? "0"),
2212
+ totalYRaw: String(row.totalYRaw ?? "0"),
2213
+ }));
2214
+
2215
+ if (!flags.has("live")) {
2216
+ const result = {
2217
+ live: false,
2218
+ pool,
2219
+ wallet: walletAddress,
2220
+ positions: summaryPositions,
2221
+ };
2222
+ emit(flags.has("json") ? json(result) + "\n" : closeAllHuman(result));
2223
+ return;
2224
+ }
2225
+
2226
+ const results: AnyRow[] = [];
2227
+ const failed: AnyRow[] = [];
2228
+ for (const row of summaryPositions) {
2229
+ if (!row.position) continue;
2230
+ try {
2231
+ const prepared = await slrd.meteora.buildClosePosition({
2232
+ wallet: walletRef,
2233
+ pool,
2234
+ position: row.position,
2235
+ });
2236
+ const executed = await finishWrite(slrd, prepared, flags);
2237
+ results.push({ position: row.position, result: executed });
2238
+ } catch (error: any) {
2239
+ failed.push({
2240
+ position: row.position,
2241
+ error: String(error?.message ?? error),
2242
+ });
2243
+ if (!flags.has("continue-on-error")) break;
2244
+ }
2245
+ }
2246
+ const result = {
2247
+ live: true,
2248
+ pool,
2249
+ wallet: walletAddress,
2250
+ positions: summaryPositions,
2251
+ closed: results.length,
2252
+ results,
2253
+ failed,
2254
+ };
2255
+ emit(flags.has("json") ? json(result) + "\n" : closeAllHuman(result));
2256
+ return;
2257
+ }
2258
+
2259
+ if (action === "close") {
2260
+ const position = values[1];
2261
+ if (!position)
2262
+ throw new Error(
2263
+ "Usage: slrd meteora close <position> --wallet <wallet> [--live]",
2264
+ );
2265
+ const wallet = requiredFlag(flags, "wallet");
2266
+ const { pool } = await resolvePositionPool(slrd, position, flags);
2267
+ const prepared = await slrd.meteora.buildClosePosition({
2268
+ wallet,
2269
+ pool,
2270
+ position,
2271
+ });
2272
+ emit(json(await finishWrite(slrd, prepared, flags)) + "\n");
2273
+ return;
2274
+ }
2275
+
2276
+ if (action === "claim") {
2277
+ const position = values[1];
2278
+ if (!position)
2279
+ throw new Error(
2280
+ "Usage: slrd meteora claim <position> --wallet <wallet> [--kind fees|rewards|all] [--live]",
2281
+ );
2282
+ const wallet = requiredFlag(flags, "wallet");
2283
+ const { pool } = await resolvePositionPool(slrd, position, flags);
2284
+ const kind = flag(flags, "kind") ?? "all";
2285
+ const prepared =
2286
+ kind === "fees"
2287
+ ? await slrd.meteora.buildClaimFees({ wallet, pool, position })
2288
+ : kind === "rewards"
2289
+ ? await slrd.meteora.buildClaimRewards({ wallet, pool, position })
2290
+ : kind === "all"
2291
+ ? await slrd.meteora.buildClaimPositionRewards({
2292
+ wallet,
2293
+ pool,
2294
+ position,
2295
+ })
2296
+ : null;
2297
+ if (!prepared) throw new Error("--kind must be fees, rewards, or all");
2298
+ emit(json(await finishWrite(slrd, prepared, flags)) + "\n");
2299
+ return;
2300
+ }
2301
+
2302
+ if (action === "claim-all") {
2303
+ const pool = values[1];
2304
+ if (!pool)
2305
+ throw new Error(
2306
+ "Usage: slrd meteora claim-all <pool> --wallet <wallet> [--kind fees|rewards|all] [--live]",
2307
+ );
2308
+ const wallet = requiredFlag(flags, "wallet");
2309
+ const kind = flag(flags, "kind") ?? "all";
2310
+ const prepared =
2311
+ kind === "fees"
2312
+ ? await slrd.meteora.buildClaimAllFees({ wallet, pool })
2313
+ : kind === "rewards"
2314
+ ? await slrd.meteora.buildClaimAllLmRewards({ wallet, pool })
2315
+ : kind === "all"
2316
+ ? await slrd.meteora.buildClaimAllRewards({ wallet, pool })
2317
+ : null;
2318
+ if (!prepared) throw new Error("--kind must be fees, rewards, or all");
2319
+ emit(json(await finishWrite(slrd, prepared, flags)) + "\n");
2320
+ return;
2321
+ }
2322
+
2323
+ if (action === "swap") {
2324
+ const pool = values[1];
2325
+ if (!pool)
2326
+ throw new Error(
2327
+ "Usage: slrd meteora swap <pool> --wallet <wallet> (--in-x N|--in-y N|--out-x N|--out-y N) [--live]",
2328
+ );
2329
+ const wallet = requiredFlag(flags, "wallet");
2330
+ const direction = exactDirection(flags);
2331
+ const slippageBps = integerFlag(flags, "slippage-bps", 100);
2332
+ const prepared =
2333
+ direction.kind === "in"
2334
+ ? await slrd.meteora.buildSwapExactIn({
2335
+ wallet,
2336
+ pool,
2337
+ swapForY: direction.swapForY,
2338
+ amountIn: direction.amount,
2339
+ slippageBps,
2340
+ allowPartialFill: flags.has("allow-partial-fill"),
2341
+ maxExtraBinArrays: integerFlag(flags, "max-extra-bin-arrays"),
2342
+ })
2343
+ : await slrd.meteora.buildSwapExactOut({
2344
+ wallet,
2345
+ pool,
2346
+ swapForY: direction.swapForY,
2347
+ amountOut: direction.amount,
2348
+ slippageBps,
2349
+ maxExtraBinArrays: integerFlag(flags, "max-extra-bin-arrays"),
2350
+ });
2351
+ emit(json(await finishWrite(slrd, prepared, flags)) + "\n");
2352
+ return;
2353
+ }
2354
+
2355
+ throw new Error(`Unknown Meteora command: ${action}. Run: slrd meteora help`);
2356
+ }