@darksol/terminal 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUDIT-2026-03-14.md +241 -0
- package/README.md +106 -1
- package/package.json +1 -1
- package/src/agent/autonomous.js +465 -0
- package/src/agent/strategy-evaluator.js +166 -0
- package/src/cli.js +286 -0
- package/src/config/keys.js +16 -0
- package/src/config/store.js +34 -0
- package/src/llm/intent.js +2 -0
- package/src/services/gas.js +35 -42
- package/src/services/watch.js +67 -61
- package/src/services/whale-monitor.js +388 -0
- package/src/services/whale.js +421 -0
- package/src/trading/arb-dexes.js +291 -0
- package/src/trading/arb.js +923 -0
- package/src/trading/index.js +2 -0
- package/src/ui/dashboard.js +596 -0
- package/src/wallet/history.js +47 -46
- package/src/wallet/portfolio.js +75 -87
- package/src/web/commands.js +55 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import EventEmitter from 'node:events';
|
|
2
|
+
import fetch from 'node-fetch';
|
|
3
|
+
import { ethers } from 'ethers';
|
|
4
|
+
import { getConfig, setConfig, getRPC } from '../config/store.js';
|
|
5
|
+
import { getApiKey } from '../config/keys.js';
|
|
6
|
+
import { executeSwap } from '../trading/swap.js';
|
|
7
|
+
import { theme } from '../ui/theme.js';
|
|
8
|
+
import { kvDisplay, success, error, warn, info, table, formatAddress } from '../ui/components.js';
|
|
9
|
+
import { showSection } from '../ui/banner.js';
|
|
10
|
+
|
|
11
|
+
const WHALE_CONFIG_KEY = 'whales';
|
|
12
|
+
const DEFAULT_CHAIN = 'base';
|
|
13
|
+
const DEFAULT_POLL_INTERVAL_MS = 15000;
|
|
14
|
+
const TOKEN_ABI = [
|
|
15
|
+
'function symbol() view returns (string)',
|
|
16
|
+
'function decimals() view returns (uint8)',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const EXPLORER_APIS = {
|
|
20
|
+
base: 'https://api.basescan.org/api',
|
|
21
|
+
ethereum: 'https://api.etherscan.io/api',
|
|
22
|
+
arbitrum: 'https://api.arbiscan.io/api',
|
|
23
|
+
polygon: 'https://api.polygonscan.com/api',
|
|
24
|
+
optimism: 'https://api-optimistic.etherscan.io/api',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const STABLE_SYMBOLS = new Set(['USDC', 'USDT', 'USDBC', 'DAI']);
|
|
28
|
+
|
|
29
|
+
const whaleEvents = new EventEmitter();
|
|
30
|
+
whaleEvents.setMaxListeners(100);
|
|
31
|
+
|
|
32
|
+
const whaleDeps = {
|
|
33
|
+
fetch,
|
|
34
|
+
executeSwap,
|
|
35
|
+
now: () => Date.now(),
|
|
36
|
+
providerFactory: (chain) => new ethers.JsonRpcProvider(getRPC(chain)),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function getWhaleStore() {
|
|
40
|
+
const current = getConfig(WHALE_CONFIG_KEY);
|
|
41
|
+
return {
|
|
42
|
+
tracked: current?.tracked || {},
|
|
43
|
+
feed: Array.isArray(current?.feed) ? current.feed : [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function saveWhaleStore(store) {
|
|
48
|
+
setConfig(WHALE_CONFIG_KEY, store);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeAddress(address) {
|
|
52
|
+
if (!ethers.isAddress(address)) {
|
|
53
|
+
throw new Error(`Invalid wallet address: ${address}`);
|
|
54
|
+
}
|
|
55
|
+
return ethers.getAddress(address);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeChain(chain) {
|
|
59
|
+
const value = (chain || DEFAULT_CHAIN).toLowerCase();
|
|
60
|
+
if (!EXPLORER_APIS[value]) {
|
|
61
|
+
throw new Error(`Unsupported chain: ${chain}`);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function trackKey(address) {
|
|
67
|
+
return normalizeAddress(address).toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getTrackedMap() {
|
|
71
|
+
return getWhaleStore().tracked;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function saveTrackedMap(tracked) {
|
|
75
|
+
const store = getWhaleStore();
|
|
76
|
+
store.tracked = tracked;
|
|
77
|
+
saveWhaleStore(store);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatWhaleTime(value) {
|
|
81
|
+
if (!value) return theme.dim('—');
|
|
82
|
+
return new Date(value).toLocaleString('en-US', {
|
|
83
|
+
month: 'short',
|
|
84
|
+
day: 'numeric',
|
|
85
|
+
hour: '2-digit',
|
|
86
|
+
minute: '2-digit',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function lastSeenActivity(txs = []) {
|
|
91
|
+
const latest = txs[0];
|
|
92
|
+
if (!latest) return null;
|
|
93
|
+
return new Date(parseInt(latest.timeStamp || '0', 10) * 1000).toISOString();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function createTrackedRecord(address, options = {}, current = {}) {
|
|
97
|
+
const normalized = normalizeAddress(address);
|
|
98
|
+
const nowIso = new Date(whaleDeps.now()).toISOString();
|
|
99
|
+
return {
|
|
100
|
+
address: normalized,
|
|
101
|
+
chain: normalizeChain(options.chain || current.chain || DEFAULT_CHAIN),
|
|
102
|
+
label: options.label ?? current.label ?? '',
|
|
103
|
+
notify: typeof options.notify === 'boolean' ? options.notify : (current.notify ?? true),
|
|
104
|
+
startedAt: current.startedAt || nowIso,
|
|
105
|
+
updatedAt: nowIso,
|
|
106
|
+
lastActivity: current.lastActivity || null,
|
|
107
|
+
lastSeenHash: current.lastSeenHash || null,
|
|
108
|
+
lastCheckedAt: current.lastCheckedAt || null,
|
|
109
|
+
knownTokens: Array.isArray(current.knownTokens) ? current.knownTokens : [],
|
|
110
|
+
mirror: current.mirror || null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildExplorerUrl(chain, params) {
|
|
115
|
+
const url = new URL(EXPLORER_APIS[chain]);
|
|
116
|
+
for (const [key, value] of Object.entries(params)) {
|
|
117
|
+
url.searchParams.set(key, String(value));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const apiKey = getApiKey('etherscan');
|
|
121
|
+
if (apiKey) {
|
|
122
|
+
url.searchParams.set('apikey', apiKey);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return url.toString();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function explorerCall(chain, params) {
|
|
129
|
+
const response = await whaleDeps.fetch(buildExplorerUrl(chain, params));
|
|
130
|
+
const data = await response.json();
|
|
131
|
+
|
|
132
|
+
if (data.status === '0' && data.message && !String(data.result || '').includes('No transactions')) {
|
|
133
|
+
throw new Error(data.result || data.message);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return data;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function fetchExplorerTransactions(address, options = {}) {
|
|
140
|
+
const normalized = normalizeAddress(address);
|
|
141
|
+
const chain = normalizeChain(options.chain || DEFAULT_CHAIN);
|
|
142
|
+
const limit = Number(options.limit || 10);
|
|
143
|
+
const data = await explorerCall(chain, {
|
|
144
|
+
module: 'account',
|
|
145
|
+
action: 'txlist',
|
|
146
|
+
address: normalized,
|
|
147
|
+
startblock: 0,
|
|
148
|
+
endblock: 99999999,
|
|
149
|
+
page: 1,
|
|
150
|
+
offset: Math.max(limit, 1),
|
|
151
|
+
sort: 'desc',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return Array.isArray(data.result) ? data.result.slice(0, limit) : [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function getWhaleActivity(address, limit = 10, options = {}) {
|
|
158
|
+
const normalized = normalizeAddress(address);
|
|
159
|
+
const chain = normalizeChain(options.chain || DEFAULT_CHAIN);
|
|
160
|
+
const txs = await fetchExplorerTransactions(normalized, { chain, limit });
|
|
161
|
+
|
|
162
|
+
if (!options.silent) {
|
|
163
|
+
showSection(`WHALE ACTIVITY — ${chain.toUpperCase()}`);
|
|
164
|
+
kvDisplay([
|
|
165
|
+
['Wallet', normalized],
|
|
166
|
+
['Transactions', String(txs.length)],
|
|
167
|
+
]);
|
|
168
|
+
console.log('');
|
|
169
|
+
|
|
170
|
+
if (!txs.length) {
|
|
171
|
+
info('No recent transactions found');
|
|
172
|
+
return txs;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const rows = txs.map((tx) => {
|
|
176
|
+
const value = tx.value ? `${Number(ethers.formatEther(tx.value)).toFixed(4)} ETH` : theme.dim('0 ETH');
|
|
177
|
+
const method = tx.functionName ? tx.functionName.split('(')[0] : 'transfer';
|
|
178
|
+
return [
|
|
179
|
+
tx.hash.slice(0, 10) + '...',
|
|
180
|
+
formatAddress(tx.to || tx.from),
|
|
181
|
+
value,
|
|
182
|
+
method.slice(0, 18),
|
|
183
|
+
new Date(parseInt(tx.timeStamp || '0', 10) * 1000).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
|
|
184
|
+
];
|
|
185
|
+
});
|
|
186
|
+
table(['Hash', 'Counterparty', 'Value', 'Method', 'Time'], rows);
|
|
187
|
+
console.log('');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return txs;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function trackWallet(address, options = {}) {
|
|
194
|
+
const normalized = normalizeAddress(address);
|
|
195
|
+
const tracked = getTrackedMap();
|
|
196
|
+
const key = trackKey(normalized);
|
|
197
|
+
const next = createTrackedRecord(normalized, options, tracked[key] || {});
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const latest = await fetchExplorerTransactions(normalized, { chain: next.chain, limit: 1 });
|
|
201
|
+
if (latest[0]) {
|
|
202
|
+
next.lastSeenHash = latest[0].hash;
|
|
203
|
+
next.lastActivity = lastSeenActivity(latest);
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
warn(`Tracking started without initial explorer sync: ${err.message}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
tracked[key] = next;
|
|
210
|
+
saveTrackedMap(tracked);
|
|
211
|
+
|
|
212
|
+
success(`Tracking ${next.label || formatAddress(normalized)} on ${next.chain}`);
|
|
213
|
+
kvDisplay([
|
|
214
|
+
['Wallet', normalized],
|
|
215
|
+
['Label', next.label || theme.dim('(none)')],
|
|
216
|
+
['Notify', next.notify ? theme.success('on') : theme.dim('off')],
|
|
217
|
+
['Last Activity', formatWhaleTime(next.lastActivity)],
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
return next;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function listTracked(options = {}) {
|
|
224
|
+
const tracked = Object.values(getTrackedMap()).sort((a, b) => {
|
|
225
|
+
return (b.updatedAt || '').localeCompare(a.updatedAt || '');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (!options.silent) {
|
|
229
|
+
showSection('WHALE TRACKER');
|
|
230
|
+
if (!tracked.length) {
|
|
231
|
+
info('No tracked wallets');
|
|
232
|
+
return tracked;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const rows = tracked.map((entry) => [
|
|
236
|
+
entry.label || theme.dim('unnamed'),
|
|
237
|
+
formatAddress(entry.address),
|
|
238
|
+
entry.chain,
|
|
239
|
+
entry.mirror ? theme.success('on') : theme.dim('off'),
|
|
240
|
+
formatWhaleTime(entry.lastActivity),
|
|
241
|
+
]);
|
|
242
|
+
table(['Label', 'Wallet', 'Chain', 'Mirror', 'Last Activity'], rows);
|
|
243
|
+
console.log('');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return tracked;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function stopTracking(address) {
|
|
250
|
+
const tracked = getTrackedMap();
|
|
251
|
+
const key = trackKey(address);
|
|
252
|
+
const existing = tracked[key];
|
|
253
|
+
|
|
254
|
+
if (!existing) {
|
|
255
|
+
warn(`Wallet not tracked: ${address}`);
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
delete tracked[key];
|
|
260
|
+
saveTrackedMap(tracked);
|
|
261
|
+
success(`Stopped tracking ${existing.label || formatAddress(existing.address)}`);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function mirrorTrade(address, options = {}) {
|
|
266
|
+
const tracked = getTrackedMap();
|
|
267
|
+
const key = trackKey(address);
|
|
268
|
+
const existing = tracked[key];
|
|
269
|
+
|
|
270
|
+
if (!existing) {
|
|
271
|
+
throw new Error('Track the wallet before enabling mirror trading');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
existing.mirror = {
|
|
275
|
+
enabled: true,
|
|
276
|
+
maxPerTrade: options.maxPerTrade ? Number(options.maxPerTrade) : null,
|
|
277
|
+
slippage: options.slippage !== undefined ? Number(options.slippage) : 2,
|
|
278
|
+
dryRun: Boolean(options.dryRun),
|
|
279
|
+
updatedAt: new Date(whaleDeps.now()).toISOString(),
|
|
280
|
+
};
|
|
281
|
+
existing.updatedAt = new Date(whaleDeps.now()).toISOString();
|
|
282
|
+
tracked[key] = existing;
|
|
283
|
+
saveTrackedMap(tracked);
|
|
284
|
+
|
|
285
|
+
success(`Mirror trading enabled for ${existing.label || formatAddress(existing.address)}`);
|
|
286
|
+
kvDisplay([
|
|
287
|
+
['Max Per Trade', existing.mirror.maxPerTrade ? `$${existing.mirror.maxPerTrade} USDC` : theme.dim('no cap')],
|
|
288
|
+
['Slippage', `${existing.mirror.slippage}%`],
|
|
289
|
+
['Mode', existing.mirror.dryRun ? theme.warning('dry-run') : theme.success('live')],
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
return existing.mirror;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function getTrackedWallet(address) {
|
|
296
|
+
return getTrackedMap()[trackKey(address)] || null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function updateTrackedWallet(address, updater) {
|
|
300
|
+
const tracked = getTrackedMap();
|
|
301
|
+
const key = trackKey(address);
|
|
302
|
+
const current = tracked[key];
|
|
303
|
+
if (!current) return null;
|
|
304
|
+
const next = typeof updater === 'function' ? updater(current) : updater;
|
|
305
|
+
tracked[key] = next;
|
|
306
|
+
saveTrackedMap(tracked);
|
|
307
|
+
return next;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function appendWhaleFeed(event) {
|
|
311
|
+
const store = getWhaleStore();
|
|
312
|
+
const nextEvent = {
|
|
313
|
+
...event,
|
|
314
|
+
createdAt: event.createdAt || new Date(whaleDeps.now()).toISOString(),
|
|
315
|
+
};
|
|
316
|
+
store.feed = [...store.feed, nextEvent].slice(-200);
|
|
317
|
+
saveWhaleStore(store);
|
|
318
|
+
return nextEvent;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function getWhaleFeed(limit = 50) {
|
|
322
|
+
return getWhaleStore().feed.slice(-limit);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function getTokenMeta(address, chain, provider) {
|
|
326
|
+
if (!address || address === ethers.ZeroAddress) {
|
|
327
|
+
return { address: ethers.ZeroAddress, symbol: chain === 'polygon' ? 'POL' : 'ETH', decimals: 18 };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const rpc = provider || whaleDeps.providerFactory(chain);
|
|
332
|
+
const contract = new ethers.Contract(address, TOKEN_ABI, rpc);
|
|
333
|
+
const [symbol, decimals] = await Promise.all([
|
|
334
|
+
contract.symbol(),
|
|
335
|
+
contract.decimals(),
|
|
336
|
+
]);
|
|
337
|
+
return { address, symbol, decimals: Number(decimals) };
|
|
338
|
+
} catch {
|
|
339
|
+
return { address, symbol: formatAddress(address), decimals: 18 };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function formatMirrorAmount(amountRaw, decimals) {
|
|
344
|
+
return Number(ethers.formatUnits(amountRaw, decimals)).toString();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function runMirrorTrade(wallet, swapDetails) {
|
|
348
|
+
const tracked = getTrackedWallet(wallet.address);
|
|
349
|
+
if (!tracked?.mirror?.enabled) return null;
|
|
350
|
+
|
|
351
|
+
const mirror = tracked.mirror;
|
|
352
|
+
const tokenIn = swapDetails.tokenInMeta?.symbol || swapDetails.tokenIn;
|
|
353
|
+
const tokenOut = swapDetails.tokenOutMeta?.symbol || swapDetails.tokenOut;
|
|
354
|
+
|
|
355
|
+
if (mirror.maxPerTrade && swapDetails.tokenInMeta?.symbol && STABLE_SYMBOLS.has(swapDetails.tokenInMeta.symbol.toUpperCase())) {
|
|
356
|
+
const rawAmount = Number(ethers.formatUnits(swapDetails.amountIn, swapDetails.tokenInMeta.decimals));
|
|
357
|
+
if (rawAmount > mirror.maxPerTrade) {
|
|
358
|
+
warn(`Mirror cap hit for ${tracked.label || formatAddress(tracked.address)} — ${rawAmount} ${swapDetails.tokenInMeta.symbol} > ${mirror.maxPerTrade}`);
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
} else if (mirror.maxPerTrade && (!swapDetails.tokenInMeta?.symbol || !STABLE_SYMBOLS.has(swapDetails.tokenInMeta.symbol.toUpperCase()))) {
|
|
362
|
+
warn(`Skipping mirror trade for ${tracked.label || formatAddress(tracked.address)} — cannot enforce USDC cap on ${tokenIn}`);
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const payload = {
|
|
367
|
+
address: tracked.address,
|
|
368
|
+
chain: tracked.chain,
|
|
369
|
+
txHash: swapDetails.txHash,
|
|
370
|
+
tokenIn,
|
|
371
|
+
tokenOut,
|
|
372
|
+
amount: formatMirrorAmount(swapDetails.amountIn, swapDetails.tokenInMeta?.decimals || 18),
|
|
373
|
+
slippage: mirror.slippage,
|
|
374
|
+
dryRun: mirror.dryRun,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
if (mirror.dryRun) {
|
|
378
|
+
info(`Dry-run mirror: ${payload.amount} ${tokenIn} -> ${tokenOut}`);
|
|
379
|
+
whaleEvents.emit('whale:mirror-executed', { ...payload, executed: false, dryRun: true });
|
|
380
|
+
return payload;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
await whaleDeps.executeSwap({
|
|
384
|
+
tokenIn: swapDetails.tokenIn,
|
|
385
|
+
tokenOut: swapDetails.tokenOut,
|
|
386
|
+
amount: payload.amount,
|
|
387
|
+
slippage: mirror.slippage,
|
|
388
|
+
});
|
|
389
|
+
whaleEvents.emit('whale:mirror-executed', { ...payload, executed: true, dryRun: false });
|
|
390
|
+
return payload;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function registerWhaleEvent(type, payload) {
|
|
394
|
+
const event = appendWhaleFeed({ type, ...payload });
|
|
395
|
+
whaleEvents.emit(type, event);
|
|
396
|
+
return event;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function __setWhaleDeps(overrides = {}) {
|
|
400
|
+
Object.assign(whaleDeps, overrides);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function __resetWhaleDeps() {
|
|
404
|
+
whaleDeps.fetch = fetch;
|
|
405
|
+
whaleDeps.executeSwap = executeSwap;
|
|
406
|
+
whaleDeps.now = () => Date.now();
|
|
407
|
+
whaleDeps.providerFactory = (chain) => new ethers.JsonRpcProvider(getRPC(chain));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function __resetWhaleStore() {
|
|
411
|
+
setConfig(WHALE_CONFIG_KEY, { tracked: {}, feed: [] });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export {
|
|
415
|
+
DEFAULT_CHAIN,
|
|
416
|
+
DEFAULT_POLL_INTERVAL_MS,
|
|
417
|
+
EXPLORER_APIS,
|
|
418
|
+
STABLE_SYMBOLS,
|
|
419
|
+
WHALE_CONFIG_KEY,
|
|
420
|
+
whaleEvents,
|
|
421
|
+
};
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* arb-dexes.js — DEX Adapter Registry
|
|
3
|
+
* Each adapter implements: getQuote(tokenIn, tokenOut, amountIn, chain, provider)
|
|
4
|
+
* Returns: { amountOut: bigint, fee: number, gasEstimate: bigint }
|
|
5
|
+
*/
|
|
6
|
+
import { ethers } from 'ethers';
|
|
7
|
+
|
|
8
|
+
// ═══════════════════════════════════════════════════════════════
|
|
9
|
+
// VERIFIED CONTRACT ADDRESSES
|
|
10
|
+
// ═══════════════════════════════════════════════════════════════
|
|
11
|
+
|
|
12
|
+
export const DEX_ADDRESSES = {
|
|
13
|
+
uniswapV3: {
|
|
14
|
+
base: {
|
|
15
|
+
router: '0x2626664c2603336E57B271c5C0b26F421741e481', // SwapRouter02
|
|
16
|
+
quoter: '0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a', // QuoterV2
|
|
17
|
+
},
|
|
18
|
+
ethereum: {
|
|
19
|
+
router: '0xE592427A0AEce92De3Edee1F18E0157C05861564', // SwapRouter V1
|
|
20
|
+
quoter: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e', // QuoterV2
|
|
21
|
+
},
|
|
22
|
+
arbitrum: {
|
|
23
|
+
router: '0xE592427A0AEce92De3Edee1F18E0157C05861564',
|
|
24
|
+
quoter: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
|
|
25
|
+
},
|
|
26
|
+
optimism: {
|
|
27
|
+
router: '0xE592427A0AEce92De3Edee1F18E0157C05861564',
|
|
28
|
+
quoter: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
|
|
29
|
+
},
|
|
30
|
+
polygon: {
|
|
31
|
+
router: '0xE592427A0AEce92De3Edee1F18E0157C05861564',
|
|
32
|
+
quoter: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
aerodrome: {
|
|
36
|
+
base: {
|
|
37
|
+
router: '0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43',
|
|
38
|
+
factory: '0x420DD381b31aEf6683db6B902084cB0FFECe40Da',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
sushiswap: {
|
|
42
|
+
base: {
|
|
43
|
+
router: '0xFB7eF66a7e61224DD6FcD0D7d9C3Ae5362F52e76', // SushiSwap V3 RouteProcessor3
|
|
44
|
+
quoter: '0xb1E835Dc2785b52265711e17fCCb0fd018226a6e', // SushiSwap QuoterV2
|
|
45
|
+
},
|
|
46
|
+
ethereum: {
|
|
47
|
+
router: '0x2c9E897Ed5A48BbB2da7A4EF68BC9FC1CD12Bb7B',
|
|
48
|
+
quoter: '0x64e829B4fE5ef4dF9E74E44c0d1ABb4E7d253E96',
|
|
49
|
+
},
|
|
50
|
+
arbitrum: {
|
|
51
|
+
router: '0xb590D17D71E7Ff2F332F77fb85Fc45A03D4DAf40',
|
|
52
|
+
quoter: '0x0524E833cCD057e4d7A296e3aaAb9f7675964Ce1',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
velodrome: {
|
|
56
|
+
optimism: {
|
|
57
|
+
router: '0xa062aE8A9c5e11aaA026fc2670B0D65cCc8B2858',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
quickswap: {
|
|
61
|
+
polygon: {
|
|
62
|
+
router: '0xf5b509bB0909a69B1c207E495f687a596C168E12', // QuickSwap V3
|
|
63
|
+
quoter: '0xa15F0D7377B2A0C0c10db057f641beD21028FC89',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
camelot: {
|
|
67
|
+
arbitrum: {
|
|
68
|
+
router: '0xc873fEcbd354f5A56E00E710B90EF4201db2448d', // Camelot V2 Router
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ═══════════════════════════════════════════════════════════════
|
|
74
|
+
// ABIs
|
|
75
|
+
// ═══════════════════════════════════════════════════════════════
|
|
76
|
+
|
|
77
|
+
const QUOTER_V2_ABI = [
|
|
78
|
+
'function quoteExactInputSingle(tuple(address tokenIn, address tokenOut, uint256 amountIn, uint24 fee, uint160 sqrtPriceLimitX96) params) external returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)',
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const AERODROME_ROUTER_ABI = [
|
|
82
|
+
'function getAmountsOut(uint256 amountIn, tuple(address from, address to, bool stable, address factory)[] routes) external view returns (uint256[] amounts)',
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const VELODROME_ROUTER_ABI = [
|
|
86
|
+
'function getAmountsOut(uint256 amountIn, tuple(address from, address to, bool stable)[] routes) external view returns (uint256[] amounts)',
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const CAMELOT_ROUTER_ABI = [
|
|
90
|
+
'function getAmountsOut(uint amountIn, address[] path) external view returns (uint[] amounts)',
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const QUICKSWAP_QUOTER_ABI = [
|
|
94
|
+
'function quoteExactInputSingle(address tokenIn, address tokenOut, uint256 amountIn, uint160 limitSqrtPrice) external returns (uint256 amountOut, uint16 fee)',
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
// ═══════════════════════════════════════════════════════════════
|
|
98
|
+
// ADAPTER IMPLEMENTATIONS
|
|
99
|
+
// ═══════════════════════════════════════════════════════════════
|
|
100
|
+
|
|
101
|
+
async function getUniswapV3Quote(tokenIn, tokenOut, amountIn, chain, provider, overrideAddrs = {}) {
|
|
102
|
+
const addrs = (overrideAddrs[chain]?.uniswapV3) || DEX_ADDRESSES.uniswapV3[chain];
|
|
103
|
+
if (!addrs?.quoter) throw new Error(`No Uniswap V3 quoter for chain: ${chain}`);
|
|
104
|
+
|
|
105
|
+
const quoter = new ethers.Contract(addrs.quoter, QUOTER_V2_ABI, provider);
|
|
106
|
+
const feeTiers = [500, 3000, 10000];
|
|
107
|
+
|
|
108
|
+
let bestOut = 0n;
|
|
109
|
+
let bestFee = 3000;
|
|
110
|
+
let bestGas = 180000n;
|
|
111
|
+
|
|
112
|
+
for (const fee of feeTiers) {
|
|
113
|
+
try {
|
|
114
|
+
const result = await quoter.quoteExactInputSingle.staticCall({
|
|
115
|
+
tokenIn, tokenOut, amountIn, fee, sqrtPriceLimitX96: 0,
|
|
116
|
+
});
|
|
117
|
+
if (result[0] > bestOut) {
|
|
118
|
+
bestOut = result[0];
|
|
119
|
+
bestFee = fee;
|
|
120
|
+
bestGas = result[3] || 180000n;
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// fee tier may not have liquidity — skip
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (bestOut === 0n) throw new Error(`No Uniswap V3 liquidity for pair on ${chain}`);
|
|
128
|
+
return { amountOut: bestOut, fee: bestFee, gasEstimate: bestGas };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function getAerodromeQuote(tokenIn, tokenOut, amountIn, provider) {
|
|
132
|
+
const { router, factory } = DEX_ADDRESSES.aerodrome.base;
|
|
133
|
+
const routerContract = new ethers.Contract(router, AERODROME_ROUTER_ABI, provider);
|
|
134
|
+
|
|
135
|
+
let bestOut = 0n;
|
|
136
|
+
|
|
137
|
+
for (const stable of [false, true]) {
|
|
138
|
+
try {
|
|
139
|
+
const routes = [{ from: tokenIn, to: tokenOut, stable, factory }];
|
|
140
|
+
const amounts = await routerContract.getAmountsOut(amountIn, routes);
|
|
141
|
+
const out = amounts[amounts.length - 1];
|
|
142
|
+
if (out > bestOut) bestOut = out;
|
|
143
|
+
} catch {
|
|
144
|
+
// pool may not exist
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (bestOut === 0n) throw new Error('No Aerodrome liquidity for pair');
|
|
149
|
+
return { amountOut: bestOut, fee: 0, gasEstimate: 200000n };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function getSushiswapQuote(tokenIn, tokenOut, amountIn, chain, provider) {
|
|
153
|
+
const addrs = DEX_ADDRESSES.sushiswap[chain];
|
|
154
|
+
if (!addrs?.quoter) throw new Error(`No SushiSwap quoter for chain: ${chain}`);
|
|
155
|
+
|
|
156
|
+
// SushiSwap V3 quoter shares the same interface as Uniswap V3 QuoterV2
|
|
157
|
+
const quoter = new ethers.Contract(addrs.quoter, QUOTER_V2_ABI, provider);
|
|
158
|
+
const feeTiers = [500, 3000, 10000];
|
|
159
|
+
|
|
160
|
+
let bestOut = 0n;
|
|
161
|
+
let bestFee = 3000;
|
|
162
|
+
let bestGas = 180000n;
|
|
163
|
+
|
|
164
|
+
for (const fee of feeTiers) {
|
|
165
|
+
try {
|
|
166
|
+
const result = await quoter.quoteExactInputSingle.staticCall({
|
|
167
|
+
tokenIn, tokenOut, amountIn, fee, sqrtPriceLimitX96: 0,
|
|
168
|
+
});
|
|
169
|
+
if (result[0] > bestOut) {
|
|
170
|
+
bestOut = result[0];
|
|
171
|
+
bestFee = fee;
|
|
172
|
+
bestGas = result[3] || 180000n;
|
|
173
|
+
}
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (bestOut === 0n) throw new Error(`No SushiSwap liquidity for pair on ${chain}`);
|
|
178
|
+
return { amountOut: bestOut, fee: bestFee, gasEstimate: bestGas };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function getVelodromeQuote(tokenIn, tokenOut, amountIn, provider) {
|
|
182
|
+
const { router } = DEX_ADDRESSES.velodrome.optimism;
|
|
183
|
+
const routerContract = new ethers.Contract(router, VELODROME_ROUTER_ABI, provider);
|
|
184
|
+
|
|
185
|
+
let bestOut = 0n;
|
|
186
|
+
|
|
187
|
+
for (const stable of [false, true]) {
|
|
188
|
+
try {
|
|
189
|
+
const amounts = await routerContract.getAmountsOut(amountIn, [{ from: tokenIn, to: tokenOut, stable }]);
|
|
190
|
+
const out = amounts[amounts.length - 1];
|
|
191
|
+
if (out > bestOut) bestOut = out;
|
|
192
|
+
} catch {}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (bestOut === 0n) throw new Error('No Velodrome liquidity for pair');
|
|
196
|
+
return { amountOut: bestOut, fee: 0, gasEstimate: 180000n };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function getQuickswapQuote(tokenIn, tokenOut, amountIn, provider) {
|
|
200
|
+
const { quoter } = DEX_ADDRESSES.quickswap.polygon;
|
|
201
|
+
const quoterContract = new ethers.Contract(quoter, QUICKSWAP_QUOTER_ABI, provider);
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const result = await quoterContract.quoteExactInputSingle.staticCall(tokenIn, tokenOut, amountIn, 0);
|
|
205
|
+
return { amountOut: result[0], fee: Number(result[1]), gasEstimate: 200000n };
|
|
206
|
+
} catch (e) {
|
|
207
|
+
throw new Error(`No QuickSwap liquidity: ${e.message}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function getCamelotQuote(tokenIn, tokenOut, amountIn, provider) {
|
|
212
|
+
const { router } = DEX_ADDRESSES.camelot.arbitrum;
|
|
213
|
+
const routerContract = new ethers.Contract(router, CAMELOT_ROUTER_ABI, provider);
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const amounts = await routerContract.getAmountsOut(amountIn, [tokenIn, tokenOut]);
|
|
217
|
+
return { amountOut: amounts[1], fee: 0, gasEstimate: 150000n };
|
|
218
|
+
} catch (e) {
|
|
219
|
+
throw new Error(`No Camelot liquidity: ${e.message}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ═══════════════════════════════════════════════════════════════
|
|
224
|
+
// DEX ADAPTER REGISTRY
|
|
225
|
+
// ═══════════════════════════════════════════════════════════════
|
|
226
|
+
|
|
227
|
+
export const DEX_ADAPTERS = {
|
|
228
|
+
uniswapV3: {
|
|
229
|
+
name: 'Uniswap V3',
|
|
230
|
+
chains: ['base', 'ethereum', 'arbitrum', 'optimism', 'polygon'],
|
|
231
|
+
async getQuote(tokenIn, tokenOut, amountIn, chain, provider, opts = {}) {
|
|
232
|
+
return getUniswapV3Quote(tokenIn, tokenOut, amountIn, chain, provider, opts.overrideAddrs);
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
aerodrome: {
|
|
236
|
+
name: 'Aerodrome',
|
|
237
|
+
chains: ['base'],
|
|
238
|
+
async getQuote(tokenIn, tokenOut, amountIn, chain, provider) {
|
|
239
|
+
if (chain !== 'base') throw new Error('Aerodrome is Base-only');
|
|
240
|
+
return getAerodromeQuote(tokenIn, tokenOut, amountIn, provider);
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
sushiswap: {
|
|
244
|
+
name: 'SushiSwap V3',
|
|
245
|
+
chains: ['base', 'ethereum', 'arbitrum'],
|
|
246
|
+
async getQuote(tokenIn, tokenOut, amountIn, chain, provider) {
|
|
247
|
+
return getSushiswapQuote(tokenIn, tokenOut, amountIn, chain, provider);
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
velodrome: {
|
|
251
|
+
name: 'Velodrome',
|
|
252
|
+
chains: ['optimism'],
|
|
253
|
+
async getQuote(tokenIn, tokenOut, amountIn, chain, provider) {
|
|
254
|
+
if (chain !== 'optimism') throw new Error('Velodrome is Optimism-only');
|
|
255
|
+
return getVelodromeQuote(tokenIn, tokenOut, amountIn, provider);
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
quickswap: {
|
|
259
|
+
name: 'QuickSwap V3',
|
|
260
|
+
chains: ['polygon'],
|
|
261
|
+
async getQuote(tokenIn, tokenOut, amountIn, chain, provider) {
|
|
262
|
+
if (chain !== 'polygon') throw new Error('QuickSwap is Polygon-only');
|
|
263
|
+
return getQuickswapQuote(tokenIn, tokenOut, amountIn, provider);
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
camelot: {
|
|
267
|
+
name: 'Camelot',
|
|
268
|
+
chains: ['arbitrum'],
|
|
269
|
+
async getQuote(tokenIn, tokenOut, amountIn, chain, provider) {
|
|
270
|
+
if (chain !== 'arbitrum') throw new Error('Camelot is Arbitrum-only');
|
|
271
|
+
return getCamelotQuote(tokenIn, tokenOut, amountIn, provider);
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Get all enabled adapters for a chain
|
|
278
|
+
* @param {string} chain
|
|
279
|
+
* @param {string[]} [enabledKeys] - whitelist of dex keys; null = all
|
|
280
|
+
* @returns {{ key: string, name: string, getQuote: Function }[]}
|
|
281
|
+
*/
|
|
282
|
+
export function getDexesForChain(chain, enabledKeys = null) {
|
|
283
|
+
return Object.entries(DEX_ADAPTERS)
|
|
284
|
+
.filter(([key, adapter]) => {
|
|
285
|
+
if (enabledKeys && !enabledKeys.includes(key)) return false;
|
|
286
|
+
return adapter.chains.includes(chain);
|
|
287
|
+
})
|
|
288
|
+
.map(([key, adapter]) => ({ key, ...adapter }));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export default DEX_ADAPTERS;
|