@darksol/terminal 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ };