@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.
- package/AUDIT-2026-03-14.md +241 -0
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/cli.js +93 -0
- package/src/config/store.js +28 -0
- package/src/llm/intent.js +2 -0
- package/src/trading/arb-ai.js +827 -0
- package/src/trading/arb-dexes.js +291 -0
- package/src/trading/arb.js +971 -0
- package/src/trading/index.js +2 -0
- package/src/web/commands.js +99 -0
|
@@ -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
|
+
}
|