@darksol/terminal 0.12.0 → 0.13.1

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,971 @@
1
+ /**
2
+ * arb.js — Cross-DEX Arbitrage Engine
3
+ *
4
+ * ⚠ HONEST WARNING: Most DEX arbitrage profits go to sophisticated MEV bots
5
+ * that run on dedicated infrastructure, submit bundles via Flashbots, and use
6
+ * flash loans for atomic execution. Simple two-transaction arb is almost always
7
+ * front-run. There are still edge opportunities on newer DEXs and less-watched
8
+ * pairs — especially on Base where MEV infrastructure is less developed than mainnet.
9
+ * Default to dry-run mode. Always test before going live.
10
+ */
11
+
12
+ import { ethers } from 'ethers';
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
14
+ import { join } from 'path';
15
+ import { homedir } from 'os';
16
+ import inquirer from 'inquirer';
17
+
18
+ import { getSigner } from '../wallet/manager.js';
19
+ import { getConfig, setConfig, getRPC } from '../config/store.js';
20
+ import { theme } from '../ui/theme.js';
21
+ import { spinner, kvDisplay, success, error, warn, info, table } from '../ui/components.js';
22
+ import { showSection } from '../ui/banner.js';
23
+ import { resolveToken, getTokenInfo } from './swap.js';
24
+ import { getDexesForChain, DEX_ADAPTERS } from './arb-dexes.js';
25
+ import { aiFilterOpportunity, aiScoreOpportunities } from './arb-ai.js';
26
+
27
+ // ═══════════════════════════════════════════════════════════════
28
+ // CONSTANTS & PATHS
29
+ // ═══════════════════════════════════════════════════════════════
30
+
31
+ const ARB_HISTORY_PATH = join(homedir(), '.darksol', 'arb-history.json');
32
+ const DARKSOL_DIR = join(homedir(), '.darksol');
33
+
34
+ /** Default RPC URLs (used when no custom endpoint configured) */
35
+ const DEFAULT_RPCS = {
36
+ base: 'https://mainnet.base.org',
37
+ ethereum: 'https://eth.llamarpc.com',
38
+ arbitrum: 'https://arb1.arbitrum.io/rpc',
39
+ optimism: 'https://mainnet.optimism.io',
40
+ polygon: 'https://polygon-rpc.com',
41
+ };
42
+
43
+ /** ERC-20 minimal ABI */
44
+ const ERC20_ABI = [
45
+ 'function approve(address spender, uint256 amount) returns (bool)',
46
+ 'function allowance(address owner, address spender) view returns (uint256)',
47
+ 'function balanceOf(address) view returns (uint256)',
48
+ 'function decimals() view returns (uint8)',
49
+ 'function symbol() view returns (string)',
50
+ ];
51
+
52
+ // ═══════════════════════════════════════════════════════════════
53
+ // ARB CONFIG HELPERS
54
+ // ═══════════════════════════════════════════════════════════════
55
+
56
+ function getArbConfig() {
57
+ return getConfig('arb') || getArbDefaults();
58
+ }
59
+
60
+ function getArbDefaults() {
61
+ return {
62
+ enabled: false,
63
+ minProfitUsd: 0.50,
64
+ maxTradeSize: 1.0,
65
+ gasCeiling: 0.01,
66
+ cooldownMs: 5000,
67
+ dryRun: true,
68
+ tokenAllowlist: [],
69
+ tokenDenylist: [],
70
+ endpoints: { wss: {}, rpc: {} },
71
+ dexes: {
72
+ base: ['uniswapV3', 'aerodrome', 'sushiswap'],
73
+ ethereum: ['uniswapV3', 'sushiswap'],
74
+ arbitrum: ['uniswapV3', 'sushiswap', 'camelot'],
75
+ optimism: ['uniswapV3', 'velodrome'],
76
+ polygon: ['uniswapV3', 'quickswap'],
77
+ },
78
+ pairs: [
79
+ { tokenA: 'WETH', tokenB: 'USDC' },
80
+ { tokenA: 'WETH', tokenB: 'USDT' },
81
+ ],
82
+ };
83
+ }
84
+
85
+ function saveArbConfig(arbCfg) {
86
+ setConfig('arb', arbCfg);
87
+ }
88
+
89
+ // ═══════════════════════════════════════════════════════════════
90
+ // PROVIDER HELPERS
91
+ // ═══════════════════════════════════════════════════════════════
92
+
93
+ function getProvider(chain, opts = {}) {
94
+ const arbCfg = getArbConfig();
95
+ // Custom fast RPC from arb config overrides default
96
+ const customRpc = arbCfg?.endpoints?.rpc?.[chain];
97
+ const defaultRpc = getRPC(chain) || DEFAULT_RPCS[chain];
98
+ const rpcUrl = opts.rpc || customRpc || defaultRpc;
99
+ return new ethers.JsonRpcProvider(rpcUrl);
100
+ }
101
+
102
+ function getWssProvider(chain, opts = {}) {
103
+ const arbCfg = getArbConfig();
104
+ const wssUrl = opts.wss || arbCfg?.endpoints?.wss?.[chain];
105
+ if (!wssUrl) return null;
106
+ try {
107
+ return new ethers.WebSocketProvider(wssUrl);
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ // ═══════════════════════════════════════════════════════════════
114
+ // HISTORY / STATS
115
+ // ═══════════════════════════════════════════════════════════════
116
+
117
+ function ensureDarksol() {
118
+ if (!existsSync(DARKSOL_DIR)) mkdirSync(DARKSOL_DIR, { recursive: true });
119
+ }
120
+
121
+ function loadHistory() {
122
+ ensureDarksol();
123
+ if (!existsSync(ARB_HISTORY_PATH)) return [];
124
+ try {
125
+ return JSON.parse(readFileSync(ARB_HISTORY_PATH, 'utf-8'));
126
+ } catch {
127
+ return [];
128
+ }
129
+ }
130
+
131
+ function saveHistory(entries) {
132
+ ensureDarksol();
133
+ writeFileSync(ARB_HISTORY_PATH, JSON.stringify(entries, null, 2));
134
+ }
135
+
136
+ function recordArb(entry) {
137
+ const history = loadHistory();
138
+ history.push({ ts: new Date().toISOString(), ...entry });
139
+ // Keep last 1000 entries
140
+ if (history.length > 1000) history.splice(0, history.length - 1000);
141
+ saveHistory(history);
142
+ }
143
+
144
+ // ═══════════════════════════════════════════════════════════════
145
+ // CORE SCAN LOGIC
146
+ // ═══════════════════════════════════════════════════════════════
147
+
148
+ /**
149
+ * Scan all DEX pairs for a given token pair on one chain.
150
+ * Returns array of { buyDex, sellDex, spread, amountIn, ... }
151
+ */
152
+ async function scanPair(tokenASymbol, tokenBSymbol, chain, provider, opts = {}) {
153
+ const arbCfg = getArbConfig();
154
+ const enabledDexes = arbCfg.dexes?.[chain];
155
+ const adapters = getDexesForChain(chain, enabledDexes);
156
+
157
+ if (adapters.length < 2) return [];
158
+
159
+ const tokenAAddr = resolveToken(tokenASymbol, chain);
160
+ const tokenBAddr = resolveToken(tokenBSymbol, chain);
161
+
162
+ if (!tokenAAddr || !tokenBAddr) return [];
163
+
164
+ // Skip denied tokens
165
+ const denied = arbCfg.tokenDenylist || [];
166
+ if (denied.includes(tokenAAddr) || denied.includes(tokenBAddr)) return [];
167
+
168
+ // Apply allowlist (if non-empty)
169
+ const allowed = arbCfg.tokenAllowlist || [];
170
+ if (allowed.length > 0 && (!allowed.includes(tokenAAddr) || !allowed.includes(tokenBAddr))) return [];
171
+
172
+ // Trade size: use opts override or config max, default 0.1 ETH for scanning
173
+ const tradeEth = opts.tradeSize || Math.min(arbCfg.maxTradeSize || 1.0, 0.1);
174
+ const amountIn = ethers.parseEther(tradeEth.toString());
175
+
176
+ // Collect quotes: price of tokenB per unit of tokenA
177
+ const quotes = [];
178
+ await Promise.allSettled(
179
+ adapters.map(async (adapter) => {
180
+ try {
181
+ const q = await adapter.getQuote(tokenAAddr, tokenBAddr, amountIn, chain, provider);
182
+ quotes.push({ dex: adapter.key, dexName: adapter.name, ...q });
183
+ } catch {
184
+ // no liquidity / unsupported pair — silently skip
185
+ }
186
+ })
187
+ );
188
+
189
+ if (quotes.length < 2) return [];
190
+
191
+ // Sort by amountOut descending
192
+ quotes.sort((a, b) => (a.amountOut > b.amountOut ? -1 : 1));
193
+
194
+ const opportunities = [];
195
+
196
+ for (let i = 0; i < quotes.length; i++) {
197
+ for (let j = i + 1; j < quotes.length; j++) {
198
+ const high = quotes[i]; // sell here (highest tokenB out)
199
+ const low = quotes[j]; // buy here (lowest tokenB out = cheapest tokenA)
200
+
201
+ const spread = Number((high.amountOut - low.amountOut) * 10000n / high.amountOut) / 100;
202
+ if (spread <= 0) continue;
203
+
204
+ // Estimate net profit
205
+ const feeData = await provider.getFeeData().catch(() => ({ gasPrice: 1000000000n }));
206
+ const gasPrice = feeData.gasPrice || 1000000000n;
207
+ const gasUsed = (high.gasEstimate || 180000n) + (low.gasEstimate || 180000n);
208
+ const gasCostWei = gasUsed * gasPrice;
209
+ const gasCostEth = parseFloat(ethers.formatEther(gasCostWei));
210
+
211
+ // Rough USD estimates (will be improved if ETH price available)
212
+ const ethPriceUsd = opts.ethPriceUsd || 3000;
213
+ const gasCostUsd = gasCostEth * ethPriceUsd;
214
+
215
+ // Raw spread value in tokenB units
216
+ const rawSpreadB = high.amountOut - low.amountOut;
217
+
218
+ // Convert spread to USD (approximate — tokenB is assumed USDC/USDT ≈ $1)
219
+ // For non-stable pairs this is rough; good enough for filtering
220
+ let spreadUsd;
221
+ try {
222
+ const tokenBInfo = await getTokenInfo(tokenBAddr, provider);
223
+ const decimals = tokenBInfo.decimals || 6;
224
+ spreadUsd = parseFloat(ethers.formatUnits(rawSpreadB, decimals));
225
+ } catch {
226
+ spreadUsd = 0;
227
+ }
228
+
229
+ const netProfitUsd = spreadUsd - gasCostUsd;
230
+
231
+ opportunities.push({
232
+ chain,
233
+ pair: `${tokenASymbol}/${tokenBSymbol}`,
234
+ tokenA: tokenASymbol,
235
+ tokenB: tokenBSymbol,
236
+ tokenAAddr,
237
+ tokenBAddr,
238
+ buyDex: low.dex,
239
+ buyDexName: low.dexName,
240
+ sellDex: high.dex,
241
+ sellDexName: high.dexName,
242
+ spread: spread,
243
+ amountIn,
244
+ amountInEth: tradeEth,
245
+ amountOutBuy: low.amountOut,
246
+ amountOutSell: high.amountOut,
247
+ gasCostEth,
248
+ gasCostUsd,
249
+ spreadUsd,
250
+ netProfitUsd,
251
+ gasUsed: Number(gasUsed),
252
+ buyFee: low.fee,
253
+ sellFee: high.fee,
254
+ timestamp: Date.now(),
255
+ });
256
+ }
257
+ }
258
+
259
+ return opportunities;
260
+ }
261
+
262
+ /**
263
+ * Fetch approximate ETH price from CoinGecko (cached per session)
264
+ */
265
+ let _ethPriceCache = { price: 3000, ts: 0 };
266
+ async function getEthPrice() {
267
+ if (Date.now() - _ethPriceCache.ts < 60000) return _ethPriceCache.price;
268
+ try {
269
+ const { default: fetch } = await import('node-fetch');
270
+ const resp = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd');
271
+ const data = await resp.json();
272
+ _ethPriceCache = { price: data.ethereum?.usd || 3000, ts: Date.now() };
273
+ } catch {}
274
+ return _ethPriceCache.price;
275
+ }
276
+
277
+ // ═══════════════════════════════════════════════════════════════
278
+ // DISPLAY HELPERS
279
+ // ═══════════════════════════════════════════════════════════════
280
+
281
+ function displayOpportunities(opps) {
282
+ if (opps.length === 0) {
283
+ info('No profitable arb opportunities found above threshold');
284
+ return;
285
+ }
286
+
287
+ showSection('ARB OPPORTUNITIES');
288
+
289
+ const headers = ['Pair', 'Buy DEX', 'Sell DEX', 'Spread %', 'Gross $', 'Gas Est.', 'Net Profit'];
290
+ const rows = opps.map(o => {
291
+ const spreadColor = o.spread >= 1 ? theme.success : theme.warning;
292
+ const profitColor = o.netProfitUsd > 0 ? theme.success : theme.error;
293
+
294
+ return [
295
+ theme.gold(o.pair),
296
+ theme.info(o.buyDexName),
297
+ theme.info(o.sellDexName),
298
+ spreadColor(`${o.spread.toFixed(3)}%`),
299
+ `$${o.spreadUsd.toFixed(4)}`,
300
+ theme.dim(`$${o.gasCostUsd.toFixed(4)}`),
301
+ profitColor(`$${o.netProfitUsd.toFixed(4)}`),
302
+ ];
303
+ });
304
+
305
+ table(headers, rows, { colWidths: [14, 14, 14, 11, 11, 11, 13] });
306
+ console.log('');
307
+ }
308
+
309
+ // ═══════════════════════════════════════════════════════════════
310
+ // EXPORTED COMMANDS
311
+ // ═══════════════════════════════════════════════════════════════
312
+
313
+ /**
314
+ * One-shot scan across configured DEXs
315
+ */
316
+ export async function arbScan(opts = {}) {
317
+ showSection('ARB SCAN');
318
+
319
+ const arbCfg = getArbConfig();
320
+ const chain = opts.chain || getConfig('chain') || 'base';
321
+ const pairs = opts.pair
322
+ ? [{ tokenA: opts.pair.split('/')[0], tokenB: opts.pair.split('/')[1] }]
323
+ : (arbCfg.pairs || []);
324
+ const minProfit = opts.minProfit ?? arbCfg.minProfitUsd ?? 0.50;
325
+
326
+ kvDisplay([
327
+ ['Chain', chain],
328
+ ['Pairs', pairs.map(p => `${p.tokenA}/${p.tokenB}`).join(', ')],
329
+ ['Min Profit', `$${minProfit}`],
330
+ ['Mode', arbCfg.dryRun ? theme.warning('DRY RUN') : theme.success('LIVE')],
331
+ ]);
332
+ console.log('');
333
+
334
+ const spin = spinner('Fetching quotes from DEXs...').start();
335
+
336
+ try {
337
+ const provider = getProvider(chain, opts);
338
+ const ethPriceUsd = await getEthPrice();
339
+
340
+ const allOpps = [];
341
+ for (const pair of pairs) {
342
+ const opps = await scanPair(pair.tokenA, pair.tokenB, chain, provider, { ethPriceUsd, ...opts });
343
+ allOpps.push(...opps);
344
+ }
345
+
346
+ spin.succeed(`Scanned ${pairs.length} pair(s), found ${allOpps.length} opportunity(s)`);
347
+ console.log('');
348
+
349
+ const profitable = allOpps.filter(o => o.netProfitUsd >= minProfit);
350
+
351
+ // Apply AI pattern filter (fast, no API call)
352
+ const aiFiltered = profitable.map(o => {
353
+ const aiResult = aiFilterOpportunity(o);
354
+ return { ...o, aiScore: aiResult.score, aiPass: aiResult.pass, aiReason: aiResult.reason };
355
+ });
356
+ const aiPassed = aiFiltered.filter(o => o.aiPass);
357
+ const aiSkipped = aiFiltered.length - aiPassed.length;
358
+
359
+ displayOpportunities(aiPassed);
360
+
361
+ if (aiSkipped > 0) {
362
+ info(`AI filter skipped ${aiSkipped} low-confidence opportunity(s) — run 'darksol arb learn' to improve accuracy`);
363
+ console.log('');
364
+ }
365
+
366
+ // AI deep scoring for top opportunities (uses LLM)
367
+ if (aiPassed.length > 0 && !opts.skipAi) {
368
+ const scoreSpin = spinner('AI scoring opportunities...').start();
369
+ try {
370
+ const scoring = await aiScoreOpportunities(aiPassed);
371
+ if (scoring?.scored?.length > 0) {
372
+ scoreSpin.succeed('AI risk scoring complete');
373
+ console.log('');
374
+ console.log(theme.gold(' 🧠 AI Risk Assessment:'));
375
+ for (const s of scoring.scored) {
376
+ const riskColor = s.riskScore <= 3 ? theme.success : s.riskScore <= 6 ? theme.warning : theme.error;
377
+ const recColor = s.recommendation === 'execute' ? theme.success : s.recommendation === 'watch' ? theme.warning : theme.error;
378
+ console.log(` ${theme.bright(s.pair)} — risk: ${riskColor(String(s.riskScore) + '/10')} | MEV: ${theme.dim(s.mevLikelihood)} | ${recColor(s.recommendation.toUpperCase())}`);
379
+ console.log(` ${theme.dim(s.reason)}`);
380
+ }
381
+ if (scoring.summary) {
382
+ console.log('');
383
+ console.log(` ${theme.dim('Summary: ' + scoring.summary)}`);
384
+ }
385
+ console.log('');
386
+ } else {
387
+ scoreSpin.succeed('AI scoring returned no results');
388
+ }
389
+ } catch {
390
+ scoreSpin.warn('AI scoring unavailable — showing raw results');
391
+ }
392
+ }
393
+
394
+ // Log everything (including unprofitable) to history
395
+ for (const o of allOpps) {
396
+ recordArb({ type: 'scan', ...o });
397
+ }
398
+
399
+ if (aiPassed.length > 0) {
400
+ console.log('');
401
+ console.log(theme.warning(' ⚠ MEV Warning: ') + theme.dim('simple two-tx arb is likely to be front-run.'));
402
+ console.log(theme.dim(' Use WSS endpoints + Flashbots bundles for reliable execution.'));
403
+ console.log('');
404
+ }
405
+
406
+ return aiPassed;
407
+ } catch (err) {
408
+ spin.fail('Scan failed');
409
+ error(err.message);
410
+ return [];
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Real-time monitoring loop (WSS preferred, falls back to polling)
416
+ */
417
+ export async function arbMonitor(opts = {}) {
418
+ showSection('ARB MONITOR');
419
+
420
+ const arbCfg = getArbConfig();
421
+ const chain = opts.chain || getConfig('chain') || 'base';
422
+ const execute = opts.execute === true;
423
+ const dryRun = opts.dryRun !== undefined ? opts.dryRun : arbCfg.dryRun !== false;
424
+ const minProfit = parseFloat(opts.minProfit || arbCfg.minProfitUsd || 0.50);
425
+ const pairs = arbCfg.pairs || [];
426
+
427
+ kvDisplay([
428
+ ['Chain', chain],
429
+ ['Pairs', pairs.map(p => `${p.tokenA}/${p.tokenB}`).join(', ')],
430
+ ['Min Profit', `$${minProfit}`],
431
+ ['Execute', execute ? theme.accent('YES') : theme.dim('no (scan only)')],
432
+ ['Mode', dryRun ? theme.warning('DRY RUN') : theme.success('LIVE')],
433
+ ]);
434
+ console.log('');
435
+
436
+ // Check for WSS
437
+ const wssProvider = getWssProvider(chain, opts);
438
+ const hasWss = !!wssProvider;
439
+
440
+ if (!hasWss) {
441
+ warn('No WSS endpoint configured — falling back to block polling (slower, less competitive)');
442
+ warn('Add WSS: darksol arb add-endpoint ' + chain + ' wss://...');
443
+ console.log('');
444
+ } else {
445
+ info('WSS endpoint active — real-time block monitoring enabled');
446
+ console.log('');
447
+ }
448
+
449
+ console.log(theme.dim(' Press Ctrl+C to stop'));
450
+ console.log('');
451
+
452
+ const provider = wssProvider || getProvider(chain, opts);
453
+ const ethPriceUsd = await getEthPrice();
454
+ let lastExecute = 0;
455
+ let blocksScanned = 0;
456
+ let oppsFound = 0;
457
+ let execCount = 0;
458
+
459
+ const onBlock = async (blockNumber) => {
460
+ blocksScanned++;
461
+ const blockSpin = spinner(`[Block ${blockNumber}] Scanning ${pairs.length} pair(s)...`).start();
462
+
463
+ try {
464
+ const blockProvider = getProvider(chain, opts); // fresh provider per block
465
+ const allOpps = [];
466
+
467
+ for (const pair of pairs) {
468
+ const opps = await scanPair(pair.tokenA, pair.tokenB, chain, blockProvider, { ethPriceUsd });
469
+ allOpps.push(...opps);
470
+ }
471
+
472
+ const profitable = allOpps.filter(o => o.netProfitUsd >= minProfit);
473
+
474
+ // AI pattern filter (fast, no API call)
475
+ const aiPassed = profitable.filter(o => aiFilterOpportunity(o).pass);
476
+ oppsFound += aiPassed.length;
477
+
478
+ if (aiPassed.length > 0) {
479
+ const skipped = profitable.length - aiPassed.length;
480
+ const skipNote = skipped > 0 ? ` (${skipped} AI-filtered)` : '';
481
+ blockSpin.succeed(`[Block ${blockNumber}] ${aiPassed.length} opportunity(s) found${skipNote}`);
482
+ displayOpportunities(aiPassed.slice(0, 5));
483
+
484
+ // Auto-execute if requested and cooldown satisfied
485
+ if (execute && !dryRun && Date.now() - lastExecute > (arbCfg.cooldownMs || 5000)) {
486
+ const best = aiPassed.sort((a, b) => b.netProfitUsd - a.netProfitUsd)[0];
487
+ if (best.netProfitUsd >= minProfit) {
488
+ await arbExecute({ opportunity: best, dryRun: false, skipConfirm: true });
489
+ lastExecute = Date.now();
490
+ execCount++;
491
+ }
492
+ }
493
+ } else {
494
+ blockSpin.text = `[Block ${blockNumber}] No profitable arb found (${allOpps.length} scanned, ${profitable.length} pre-filter)`;
495
+ blockSpin.succeed();
496
+ }
497
+
498
+ // Update stats in process title for visibility
499
+ process.title = `darksol arb | ${chain} | blocks:${blocksScanned} | opps:${oppsFound} | exec:${execCount}`;
500
+
501
+ } catch (err) {
502
+ blockSpin.fail(`[Block ${blockNumber}] Scan error: ${err.message}`);
503
+ }
504
+ };
505
+
506
+ if (hasWss) {
507
+ wssProvider.on('block', onBlock);
508
+ // Keep alive
509
+ await new Promise((_, reject) => {
510
+ wssProvider.on('error', reject);
511
+ process.on('SIGINT', () => {
512
+ wssProvider.removeAllListeners('block');
513
+ wssProvider.destroy?.();
514
+ process.exit(0);
515
+ });
516
+ });
517
+ } else {
518
+ // Poll-based fallback
519
+ const POLL_INTERVAL = 12000; // ~1 block on Base/Ethereum
520
+ let running = true;
521
+ process.on('SIGINT', () => { running = false; process.exit(0); });
522
+
523
+ while (running) {
524
+ try {
525
+ const blockNumber = await provider.getBlockNumber();
526
+ await onBlock(blockNumber);
527
+ } catch (err) {
528
+ error(`Poll error: ${err.message}`);
529
+ }
530
+ await new Promise(r => setTimeout(r, POLL_INTERVAL));
531
+ }
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Execute a specific arb opportunity
537
+ * NOTE: Non-atomic two-tx execution. Subject to front-running.
538
+ * Flash loan support is future-ready — the opportunity object contains all needed data.
539
+ *
540
+ * FLASH LOAN HOOK (future): Replace the two sequential txs below with a
541
+ * single flash loan + multi-call transaction. The `opportunity` object already
542
+ * contains tokenAAddr, tokenBAddr, buyDex, sellDex, amountIn, chain.
543
+ */
544
+ export async function arbExecute(opts = {}) {
545
+ const { opportunity, dryRun: dryRunOpt, skipConfirm } = opts;
546
+
547
+ if (!opportunity) {
548
+ error('No opportunity provided');
549
+ return;
550
+ }
551
+
552
+ const arbCfg = getArbConfig();
553
+ const dryRun = dryRunOpt !== undefined ? dryRunOpt : arbCfg.dryRun !== false;
554
+
555
+ showSection('ARB EXECUTE');
556
+
557
+ kvDisplay([
558
+ ['Pair', opportunity.pair],
559
+ ['Buy on', opportunity.buyDexName],
560
+ ['Sell on', opportunity.sellDexName],
561
+ ['Spread', `${opportunity.spread.toFixed(3)}%`],
562
+ ['Net Profit', `$${opportunity.netProfitUsd.toFixed(4)}`],
563
+ ['Gas Est.', `$${opportunity.gasCostUsd.toFixed(4)}`],
564
+ ['Mode', dryRun ? theme.warning('DRY RUN — no transactions will be sent') : theme.accent('LIVE — real money')],
565
+ ]);
566
+ console.log('');
567
+
568
+ // Safety checks
569
+ if (opportunity.gasCostEth > arbCfg.gasCeiling) {
570
+ error(`Gas cost (${opportunity.gasCostEth.toFixed(6)} ETH) exceeds ceiling (${arbCfg.gasCeiling} ETH)`);
571
+ recordArb({ type: 'rejected', reason: 'gas_ceiling', ...opportunity });
572
+ return;
573
+ }
574
+
575
+ if (opportunity.netProfitUsd < arbCfg.minProfitUsd) {
576
+ error(`Net profit ($${opportunity.netProfitUsd.toFixed(4)}) below minimum ($${arbCfg.minProfitUsd})`);
577
+ recordArb({ type: 'rejected', reason: 'min_profit', ...opportunity });
578
+ return;
579
+ }
580
+
581
+ if (!skipConfirm && !dryRun) {
582
+ warn('⚠ Two-transaction arb is NOT atomic and can be front-run by MEV bots.');
583
+ warn(' Only proceed if you understand the risk and have fast endpoints.');
584
+ console.log('');
585
+
586
+ const { confirm } = await inquirer.prompt([{
587
+ type: 'confirm',
588
+ name: 'confirm',
589
+ message: theme.accent('Execute arb? (real money, non-atomic)'),
590
+ default: false,
591
+ }]);
592
+ if (!confirm) {
593
+ warn('Arb cancelled');
594
+ return;
595
+ }
596
+ }
597
+
598
+ if (dryRun) {
599
+ success(`DRY RUN: Would execute arb for est. $${opportunity.netProfitUsd.toFixed(4)} profit`);
600
+ recordArb({ type: 'dry_run', ...opportunity });
601
+ return;
602
+ }
603
+
604
+ // LIVE EXECUTION
605
+ const spin = spinner('Preparing arb execution...').start();
606
+
607
+ try {
608
+ const walletName = opts.wallet || getConfig('activeWallet');
609
+ let password = opts.password;
610
+ if (!password) {
611
+ spin.stop();
612
+ const p = await inquirer.prompt([{
613
+ type: 'password',
614
+ name: 'password',
615
+ message: theme.gold('Wallet password:'),
616
+ mask: '●',
617
+ }]);
618
+ password = p.password;
619
+ spin.start();
620
+ }
621
+
622
+ const { signer, provider } = await getSigner(walletName, password);
623
+
624
+ // Dynamically import swap execution
625
+ const { executeSwap } = await import('./swap.js');
626
+
627
+ spin.text = `Step 1/2: Buy ${opportunity.tokenB} on ${opportunity.buyDexName}...`;
628
+
629
+ // Step 1: Buy tokenB on cheaper DEX
630
+ // We execute this as a standard swap using the existing executeSwap function
631
+ // This is simplified — production arb should use direct router calls for speed
632
+ await executeSwap({
633
+ tokenIn: opportunity.tokenA,
634
+ tokenOut: opportunity.tokenB,
635
+ amount: opportunity.amountInEth.toString(),
636
+ wallet: walletName,
637
+ password,
638
+ slippage: 1.0, // higher slippage for speed
639
+ confirm: true,
640
+ });
641
+
642
+ spin.text = `Step 2/2: Sell ${opportunity.tokenB} on ${opportunity.sellDexName}...`;
643
+
644
+ // NOTE: Step 2 would sell tokenB back to tokenA on the more expensive DEX
645
+ // This requires knowing the tokenB balance received from step 1
646
+ // For now we log and warn — full atomic execution requires flash loans
647
+ warn('Step 2 (sell leg) requires knowing exact tokenB received from Step 1.');
648
+ warn('In production, use flash loans for atomic execution. See: arb info');
649
+
650
+ spin.succeed('Arb execution initiated (buy leg sent)');
651
+
652
+ recordArb({ type: 'executed', status: 'partial', ...opportunity });
653
+
654
+ console.log('');
655
+ warn('⚠ Non-atomic arb: sell leg must be completed manually or via script.');
656
+
657
+ } catch (err) {
658
+ spin.fail('Arb execution failed');
659
+ error(err.message);
660
+ recordArb({ type: 'error', error: err.message, ...opportunity });
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Show historical arb statistics
666
+ */
667
+ export async function arbStats(opts = {}) {
668
+ showSection('ARB STATISTICS');
669
+
670
+ const history = loadHistory();
671
+ const days = parseInt(opts.days || '7');
672
+ const cutoff = Date.now() - days * 86400 * 1000;
673
+ const recent = history.filter(h => new Date(h.ts).getTime() > cutoff);
674
+
675
+ if (recent.length === 0) {
676
+ info(`No arb history for the past ${days} days`);
677
+ info('Run a scan: darksol arb scan');
678
+ return;
679
+ }
680
+
681
+ const scans = recent.filter(h => h.type === 'scan');
682
+ const executed = recent.filter(h => h.type === 'executed');
683
+ const dryRuns = recent.filter(h => h.type === 'dry_run');
684
+ const errors = recent.filter(h => h.type === 'error');
685
+
686
+ const totalProfitUsd = executed
687
+ .filter(h => h.status === 'success')
688
+ .reduce((sum, h) => sum + (h.netProfitUsd || 0), 0);
689
+
690
+ const totalGasUsd = executed
691
+ .reduce((sum, h) => sum + (h.gasCostUsd || 0), 0);
692
+
693
+ kvDisplay([
694
+ ['Period', `Last ${days} days`],
695
+ ['Scans', scans.length.toString()],
696
+ ['Opportunities', scans.filter(h => h.netProfitUsd > 0).length.toString()],
697
+ ['Executed', executed.length.toString()],
698
+ ['Dry Runs', dryRuns.length.toString()],
699
+ ['Errors', errors.length.toString()],
700
+ ['Total Profit', `$${totalProfitUsd.toFixed(4)}`],
701
+ ['Total Gas Paid', `$${totalGasUsd.toFixed(4)}`],
702
+ ['Net', `$${(totalProfitUsd - totalGasUsd).toFixed(4)}`],
703
+ ]);
704
+ console.log('');
705
+
706
+ // Top opportunities
707
+ const topOpps = scans
708
+ .filter(h => h.netProfitUsd > 0)
709
+ .sort((a, b) => b.netProfitUsd - a.netProfitUsd)
710
+ .slice(0, 5);
711
+
712
+ if (topOpps.length > 0) {
713
+ console.log(theme.gold(' Top Opportunities (past ') + theme.bright(`${days}d`) + theme.gold('):'));
714
+ const headers = ['Pair', 'Buy', 'Sell', 'Spread', 'Net $', 'Date'];
715
+ const rows = topOpps.map(o => [
716
+ theme.gold(o.pair || '-'),
717
+ o.buyDexName || '-',
718
+ o.sellDexName || '-',
719
+ `${(o.spread || 0).toFixed(2)}%`,
720
+ theme.success(`$${(o.netProfitUsd || 0).toFixed(4)}`),
721
+ theme.dim(new Date(o.ts).toLocaleDateString()),
722
+ ]);
723
+ table(headers, rows);
724
+ }
725
+
726
+ console.log('');
727
+ info(`Full history: ${ARB_HISTORY_PATH}`);
728
+ }
729
+
730
+ /**
731
+ * Interactive configuration
732
+ */
733
+ export async function arbConfig(opts = {}) {
734
+ showSection('ARB CONFIG');
735
+
736
+ const arbCfg = getArbConfig();
737
+
738
+ kvDisplay([
739
+ ['Status', arbCfg.enabled ? theme.success('enabled') : theme.dim('disabled')],
740
+ ['Mode', arbCfg.dryRun ? theme.warning('dry-run (safe)') : theme.accent('LIVE')],
741
+ ['Min Profit', `$${arbCfg.minProfitUsd}`],
742
+ ['Max Trade', `${arbCfg.maxTradeSize} ETH`],
743
+ ['Gas Ceiling', `${arbCfg.gasCeiling} ETH`],
744
+ ['Cooldown', `${arbCfg.cooldownMs}ms`],
745
+ ['WSS Endpoints', Object.keys(arbCfg.endpoints?.wss || {}).join(', ') || '(none)'],
746
+ ['RPC Overrides', Object.keys(arbCfg.endpoints?.rpc || {}).join(', ') || '(none)'],
747
+ ['Pairs', (arbCfg.pairs || []).map(p => `${p.tokenA}/${p.tokenB}`).join(', ')],
748
+ ]);
749
+ console.log('');
750
+
751
+ const { field } = await inquirer.prompt([{
752
+ type: 'list',
753
+ name: 'field',
754
+ message: theme.gold('What would you like to change?'),
755
+ choices: [
756
+ { name: 'Toggle dry-run mode', value: 'dryRun' },
757
+ { name: 'Minimum profit threshold', value: 'minProfitUsd' },
758
+ { name: 'Maximum trade size (ETH)', value: 'maxTradeSize' },
759
+ { name: 'Gas ceiling (ETH)', value: 'gasCeiling' },
760
+ { name: 'Cooldown between executions', value: 'cooldownMs' },
761
+ { name: 'View DEXes per chain', value: 'dexes' },
762
+ { name: '← Cancel', value: 'cancel' },
763
+ ],
764
+ }]);
765
+
766
+ if (field === 'cancel') return;
767
+
768
+ if (field === 'dryRun') {
769
+ arbCfg.dryRun = !arbCfg.dryRun;
770
+ saveArbConfig(arbCfg);
771
+ success(`Dry-run mode: ${arbCfg.dryRun ? theme.warning('ON (safe)') : theme.accent('OFF (live)')}`);
772
+ if (!arbCfg.dryRun) {
773
+ warn('Live mode enabled. Real transactions will be sent. Use with caution.');
774
+ }
775
+ return;
776
+ }
777
+
778
+ if (field === 'dexes') {
779
+ console.log('');
780
+ for (const [chain, dexes] of Object.entries(arbCfg.dexes || {})) {
781
+ console.log(` ${theme.gold(chain.padEnd(12))} ${theme.dim(dexes.join(', '))}`);
782
+ }
783
+ console.log('');
784
+ return;
785
+ }
786
+
787
+ const { value } = await inquirer.prompt([{
788
+ type: 'input',
789
+ name: 'value',
790
+ message: theme.gold(`New ${field}:`),
791
+ default: String(arbCfg[field]),
792
+ validate: v => !isNaN(parseFloat(v)) || 'Please enter a number',
793
+ }]);
794
+
795
+ arbCfg[field] = parseFloat(value);
796
+ saveArbConfig(arbCfg);
797
+ success(`${field} set to ${value}`);
798
+ }
799
+
800
+ /**
801
+ * Add a custom WSS or RPC endpoint
802
+ */
803
+ export async function arbAddEndpoint(opts = {}) {
804
+ const { chain, url } = opts;
805
+
806
+ if (!chain || !url) {
807
+ error('Usage: darksol arb add-endpoint <chain> <url>');
808
+ info('Example: darksol arb add-endpoint base wss://...');
809
+ return;
810
+ }
811
+
812
+ const supportedChains = ['base', 'ethereum', 'arbitrum', 'optimism', 'polygon'];
813
+ if (!supportedChains.includes(chain)) {
814
+ error(`Unsupported chain: ${chain}. Use: ${supportedChains.join(', ')}`);
815
+ return;
816
+ }
817
+
818
+ const arbCfg = getArbConfig();
819
+ if (!arbCfg.endpoints) arbCfg.endpoints = { wss: {}, rpc: {} };
820
+
821
+ if (url.startsWith('wss://') || url.startsWith('ws://')) {
822
+ arbCfg.endpoints.wss = arbCfg.endpoints.wss || {};
823
+ arbCfg.endpoints.wss[chain] = url;
824
+ saveArbConfig(arbCfg);
825
+ success(`WSS endpoint for ${chain} saved`);
826
+ info('WSS enables real-time block subscription for faster arb detection');
827
+ info('Recommended: QuickNode, Alchemy, or Infura WSS endpoints');
828
+ } else if (url.startsWith('https://') || url.startsWith('http://')) {
829
+ arbCfg.endpoints.rpc = arbCfg.endpoints.rpc || {};
830
+ arbCfg.endpoints.rpc[chain] = url;
831
+ saveArbConfig(arbCfg);
832
+ success(`RPC endpoint for ${chain} saved`);
833
+ } else {
834
+ error('URL must start with wss://, ws://, or https://');
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Add a token pair to scan
840
+ */
841
+ export async function arbAddPair(opts = {}) {
842
+ const { tokenA, tokenB } = opts;
843
+
844
+ if (!tokenA || !tokenB) {
845
+ error('Usage: darksol arb add-pair <tokenA> <tokenB>');
846
+ return;
847
+ }
848
+
849
+ const arbCfg = getArbConfig();
850
+ if (!arbCfg.pairs) arbCfg.pairs = [];
851
+
852
+ const exists = arbCfg.pairs.some(
853
+ p => p.tokenA.toUpperCase() === tokenA.toUpperCase() &&
854
+ p.tokenB.toUpperCase() === tokenB.toUpperCase()
855
+ );
856
+
857
+ if (exists) {
858
+ warn(`Pair ${tokenA}/${tokenB} already in list`);
859
+ return;
860
+ }
861
+
862
+ arbCfg.pairs.push({ tokenA: tokenA.toUpperCase(), tokenB: tokenB.toUpperCase() });
863
+ saveArbConfig(arbCfg);
864
+ success(`Pair ${tokenA}/${tokenB} added`);
865
+ info(`Total pairs: ${arbCfg.pairs.length}`);
866
+ }
867
+
868
+ /**
869
+ * Remove a token pair from the scan list
870
+ */
871
+ export async function arbRemovePair(opts = {}) {
872
+ const { tokenA, tokenB } = opts;
873
+
874
+ if (!tokenA || !tokenB) {
875
+ error('Usage: darksol arb remove-pair <tokenA> <tokenB>');
876
+ return;
877
+ }
878
+
879
+ const arbCfg = getArbConfig();
880
+ if (!arbCfg.pairs) { warn('No pairs configured'); return; }
881
+
882
+ const before = arbCfg.pairs.length;
883
+ arbCfg.pairs = arbCfg.pairs.filter(
884
+ p => !(p.tokenA.toUpperCase() === tokenA.toUpperCase() &&
885
+ p.tokenB.toUpperCase() === tokenB.toUpperCase())
886
+ );
887
+
888
+ if (arbCfg.pairs.length < before) {
889
+ saveArbConfig(arbCfg);
890
+ success(`Pair ${tokenA}/${tokenB} removed`);
891
+ } else {
892
+ warn(`Pair ${tokenA}/${tokenB} not found in list`);
893
+ }
894
+ }
895
+
896
+ /**
897
+ * Show the arb info / guide
898
+ */
899
+ export async function arbInfo(opts = {}) {
900
+ showSection('ARB GUIDE');
901
+
902
+ console.log(theme.gold(' What is DEX arbitrage?'));
903
+ console.log(theme.dim(' ─────────────────────────────────────────────────────'));
904
+ console.log(' Price differences for the same token pair exist across DEXes at any');
905
+ console.log(' given moment. Arb bots buy on the cheaper DEX and sell on the more');
906
+ console.log(' expensive one, pocketing the spread minus gas costs.');
907
+ console.log('');
908
+
909
+ console.log(theme.warning(' ⚠ Reality Check'));
910
+ console.log(theme.dim(' ─────────────────────────────────────────────────────'));
911
+ console.log(' ' + theme.dim('Most arb profits go to sophisticated MEV bots that:'));
912
+ console.log(' ' + theme.dim(' • Run on co-located servers near validators'));
913
+ console.log(' ' + theme.dim(' • Submit atomic flash-loan bundles via Flashbots'));
914
+ console.log(' ' + theme.dim(' • Execute in a single transaction (no front-run risk)'));
915
+ console.log(' ' + theme.dim(' • Operate with sub-millisecond reaction times'));
916
+ console.log('');
917
+ console.log(' ' + theme.info('But there are still edge opportunities:'));
918
+ console.log(' ' + theme.dim(' • Newer DEXes with less MEV infrastructure (Aerodrome, Camelot)'));
919
+ console.log(' ' + theme.dim(' • Less-watched token pairs (not just ETH/USDC)'));
920
+ console.log(' ' + theme.dim(' • During periods of high volatility (wider, faster-moving spreads)'));
921
+ console.log(' ' + theme.dim(' • On Base, where MEV is less competitive than Ethereum mainnet'));
922
+ console.log('');
923
+
924
+ console.log(theme.gold(' Why WSS Endpoints Matter'));
925
+ console.log(theme.dim(' ─────────────────────────────────────────────────────'));
926
+ console.log(' Public RPCs (mainnet.base.org, eth.llamarpc.com) introduce latency:');
927
+ console.log(' ' + theme.dim(' • Rate-limited (50-100 requests/sec)'));
928
+ console.log(' ' + theme.dim(' • Shared with thousands of other users'));
929
+ console.log(' ' + theme.dim(' • No WebSocket block subscription'));
930
+ console.log(' ' + theme.dim(' • ~200-500ms delay vs dedicated endpoints'));
931
+ console.log('');
932
+ console.log(' ' + theme.success('With a private WSS endpoint (QuickNode / Alchemy / Infura):'));
933
+ console.log(' ' + theme.dim(' • Subscribe to new blocks as they\'re produced'));
934
+ console.log(' ' + theme.dim(' • Get data 10-50x faster than HTTP polling'));
935
+ console.log(' ' + theme.dim(' • No rate limits on your own endpoint'));
936
+ console.log('');
937
+
938
+ console.log(theme.gold(' Recommended Setup'));
939
+ console.log(theme.dim(' ─────────────────────────────────────────────────────'));
940
+ const steps = [
941
+ ['1', 'Get a free WSS endpoint', 'quicknode.com, alchemy.com, or infura.io'],
942
+ ['2', 'Add it', 'darksol arb add-endpoint base wss://your-endpoint'],
943
+ ['3', 'Run a dry-run scan', 'darksol arb scan --chain base'],
944
+ ['4', 'Monitor for a few days', 'darksol arb monitor --chain base'],
945
+ ['5', 'Review stats', 'darksol arb stats --days 7'],
946
+ ['6', 'Enable live mode', 'darksol arb config → disable dry-run (carefully!)'],
947
+ ];
948
+ steps.forEach(([n, title, detail]) => {
949
+ console.log(` ${theme.gold(n + '.')} ${theme.bright(title)}`);
950
+ console.log(` ${theme.dim(detail)}`);
951
+ });
952
+ console.log('');
953
+
954
+ console.log(theme.gold(' Risk Warnings'));
955
+ console.log(theme.dim(' ─────────────────────────────────────────────────────'));
956
+ const risks = [
957
+ 'Two-transaction arb is NOT atomic — you can be front-run between steps',
958
+ 'Gas costs on Ethereum can easily wipe small spreads',
959
+ 'Price impact on your own trade may eliminate profit',
960
+ 'Smart contract bugs in DEX routers can cause unexpected losses',
961
+ 'Never risk more than you can afford to lose',
962
+ 'Flash loans are needed for professional-grade atomic arb',
963
+ ];
964
+ risks.forEach(r => warn(r));
965
+ console.log('');
966
+
967
+ info('History stored at: ' + ARB_HISTORY_PATH);
968
+ info('Config command: darksol arb config');
969
+ info('Start scanning: darksol arb scan --chain base');
970
+ console.log('');
971
+ }