@darksol/terminal 0.14.1 → 0.15.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/README.md +43 -0
- package/package.json +1 -1
- package/src/cli.js +48 -0
- package/src/llm/intent.js +17 -2
- package/src/services/health.js +131 -0
- package/src/services/scanner.js +931 -0
package/README.md
CHANGED
|
@@ -56,6 +56,12 @@ darksol bridge send --from base --to arbitrum --token ETH -a 0.1
|
|
|
56
56
|
darksol bridge status 0xTxHash...
|
|
57
57
|
darksol bridge chains
|
|
58
58
|
|
|
59
|
+
# Token security scanner
|
|
60
|
+
darksol scan 0x1234...5678 # scan a token on Base (default)
|
|
61
|
+
darksol scan 0x1234...5678 --chain ethereum # scan on a specific chain
|
|
62
|
+
darksol scan 0x1234...5678 --json # JSON output for automation
|
|
63
|
+
darksol scan 0x1234...5678 --quick # skip slow checks (honeypot sim)
|
|
64
|
+
|
|
59
65
|
# Cross-DEX arbitrage
|
|
60
66
|
darksol arb scan --chain base # AI-scored DEX price comparison
|
|
61
67
|
darksol arb monitor --chain base --execute # real-time block-by-block scanning
|
|
@@ -161,6 +167,7 @@ ai <prompt> # chat with trading assistant
|
|
|
161
167
|
| `wallet` | Create/import/manage encrypted EVM wallets | Free |
|
|
162
168
|
| `send` | Send ETH or ERC-20 tokens | Gas only |
|
|
163
169
|
| `receive` | Show receive address + chain safety hints | Free |
|
|
170
|
+
| `scan` | Token security scanner — honeypot, rug pull, red flag detection | Free |
|
|
164
171
|
| `trade` | Swap via LI.FI (31 DEXs) + Uniswap V3 fallback, snipe | Gas only |
|
|
165
172
|
| `bridge` | Cross-chain bridge via LI.FI (60 chains, 27 bridges) | Gas only |
|
|
166
173
|
| `dca` | Dollar-cost averaging engine | Gas only |
|
|
@@ -526,6 +533,42 @@ Keys can also come from environment variables (e.g., `OPENAI_API_KEY`).
|
|
|
526
533
|
|
|
527
534
|
---
|
|
528
535
|
|
|
536
|
+
## 🔍 Token Scanner
|
|
537
|
+
|
|
538
|
+
Scan any ERC-20 token for security red flags before trading.
|
|
539
|
+
|
|
540
|
+
```bash
|
|
541
|
+
# Full scan on Base (default)
|
|
542
|
+
darksol scan 0x1234...5678
|
|
543
|
+
|
|
544
|
+
# Scan on a specific chain
|
|
545
|
+
darksol scan 0x1234...5678 --chain ethereum
|
|
546
|
+
|
|
547
|
+
# Quick scan (skip honeypot simulation)
|
|
548
|
+
darksol scan 0x1234...5678 --quick
|
|
549
|
+
|
|
550
|
+
# JSON output for automation
|
|
551
|
+
darksol scan 0x1234...5678 --json
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
**8 security checks:**
|
|
555
|
+
- **Contract Verification** — is source code verified on the block explorer?
|
|
556
|
+
- **Ownership Status** — is ownership renounced or still active?
|
|
557
|
+
- **Honeypot Detection** — simulates buy+sell via Uniswap V3 Quoter
|
|
558
|
+
- **Liquidity Analysis** — finds Uniswap V3 pools, estimates USD depth
|
|
559
|
+
- **Holder Concentration** — top holder analysis, flags concentrated supply
|
|
560
|
+
- **Proxy Detection** — checks for EIP-1967 upgradeable proxy pattern
|
|
561
|
+
- **Mint Function** — scans bytecode for mint capability
|
|
562
|
+
- **Token Info** — name, symbol, decimals, total supply, deployer
|
|
563
|
+
|
|
564
|
+
**Risk levels:** LOW / MEDIUM / HIGH / CRITICAL with actionable recommendations.
|
|
565
|
+
|
|
566
|
+
**AI integration:** Ask "is this token safe?" or "scan 0x..." in `darksol chat`.
|
|
567
|
+
|
|
568
|
+
**Chains:** Base, Ethereum, Arbitrum, Optimism, Polygon.
|
|
569
|
+
|
|
570
|
+
---
|
|
571
|
+
|
|
529
572
|
## 💰 Trading
|
|
530
573
|
|
|
531
574
|
```bash
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -26,6 +26,8 @@ import { casinoBet, casinoTables, casinoStats, casinoReceipt, casinoHealth, casi
|
|
|
26
26
|
import { pokerNewGame, pokerAction, pokerStatus, pokerHistory } from './services/poker.js';
|
|
27
27
|
import { cardsCatalog, cardsOrder, cardsStatus } from './services/cards.js';
|
|
28
28
|
import { facilitatorHealth, facilitatorVerify, facilitatorSettle } from './services/facilitator.js';
|
|
29
|
+
import { healthCommand } from './services/health.js';
|
|
30
|
+
import { scanToken, displayScanResult, scanResultToJSON } from './services/scanner.js';
|
|
29
31
|
import { buildersLeaderboard, buildersLookup, buildersFeed } from './services/builders.js';
|
|
30
32
|
import { createScript, listScripts, runScript, showScript, editScript, deleteScript, cloneScript, listTemplates } from './scripts/engine.js';
|
|
31
33
|
import {
|
|
@@ -774,6 +776,43 @@ export function cli(argv) {
|
|
|
774
776
|
.description('Settle payment on-chain')
|
|
775
777
|
.action((payment) => facilitatorSettle(payment));
|
|
776
778
|
|
|
779
|
+
// ═══════════════════════════════════════
|
|
780
|
+
// TOKEN SCANNER
|
|
781
|
+
// ═══════════════════════════════════════
|
|
782
|
+
program
|
|
783
|
+
.command('scan <address>')
|
|
784
|
+
.description('🔍 Token security scanner — check for honeypots, rug pulls, red flags')
|
|
785
|
+
.option('-c, --chain <chain>', 'Target chain (base, ethereum, arbitrum, optimism, polygon)', 'base')
|
|
786
|
+
.option('--json', 'Output as JSON')
|
|
787
|
+
.option('--quick', 'Skip slow checks (honeypot simulation)')
|
|
788
|
+
.action(async (address, opts) => {
|
|
789
|
+
const { showMiniBanner } = await import('./ui/banner.js');
|
|
790
|
+
showMiniBanner();
|
|
791
|
+
|
|
792
|
+
const spin = spinner('Scanning token for security issues...').start();
|
|
793
|
+
try {
|
|
794
|
+
const result = await scanToken(address, opts.chain, {
|
|
795
|
+
quick: opts.quick,
|
|
796
|
+
});
|
|
797
|
+
spin.succeed('Scan complete');
|
|
798
|
+
|
|
799
|
+
if (opts.json) {
|
|
800
|
+
console.log(JSON.stringify(scanResultToJSON(result), null, 2));
|
|
801
|
+
} else {
|
|
802
|
+
displayScanResult(result);
|
|
803
|
+
}
|
|
804
|
+
} catch (err) {
|
|
805
|
+
spin.fail('Scan failed');
|
|
806
|
+
error(err.message);
|
|
807
|
+
if (err.message.includes('not a contract')) {
|
|
808
|
+
info('Make sure the address is a token contract, not a wallet');
|
|
809
|
+
}
|
|
810
|
+
if (err.message.includes('No RPC')) {
|
|
811
|
+
info(`Set an RPC: darksol config rpc ${opts.chain} <url>`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
|
|
777
816
|
// ═══════════════════════════════════════
|
|
778
817
|
// APPROVALS COMMANDS
|
|
779
818
|
// ═══════════════════════════════════════
|
|
@@ -1712,6 +1751,14 @@ export function cli(argv) {
|
|
|
1712
1751
|
}
|
|
1713
1752
|
});
|
|
1714
1753
|
|
|
1754
|
+
// ═══════════════════════════════════════
|
|
1755
|
+
// HEALTH CHECK
|
|
1756
|
+
// ═══════════════════════════════════════
|
|
1757
|
+
program
|
|
1758
|
+
.command('health')
|
|
1759
|
+
.description('Check status of all DARKSOL services')
|
|
1760
|
+
.action(() => healthCommand());
|
|
1761
|
+
|
|
1715
1762
|
program
|
|
1716
1763
|
.command('dash')
|
|
1717
1764
|
.description('Launch the live terminal dashboard')
|
|
@@ -2052,6 +2099,7 @@ function showCommandList() {
|
|
|
2052
2099
|
['price', 'Quick token price check'],
|
|
2053
2100
|
['watch', 'Live price monitoring + alerts'],
|
|
2054
2101
|
['gas', 'Gas prices & cost estimates'],
|
|
2102
|
+
['scan', 'Token security scanner'],
|
|
2055
2103
|
['trade', 'Swap tokens, snipe, trading'],
|
|
2056
2104
|
['arb', 'Cross-DEX arbitrage scanner'],
|
|
2057
2105
|
['auto', 'Autonomous trader strategies'],
|
package/src/llm/intent.js
CHANGED
|
@@ -69,6 +69,7 @@ ACTIONS (use the most specific one):
|
|
|
69
69
|
- "arb_scan" — scan for cross-DEX arbitrage opportunities (e.g. "find arb opportunities", "scan for price differences", "check arb on base")
|
|
70
70
|
- "arb_monitor" — start real-time arbitrage monitoring (e.g. "monitor arb", "watch for arb opportunities")
|
|
71
71
|
- "approvals" — check or revoke ERC-20 token approvals (e.g. "check my approvals", "revoke approvals", "what tokens have I approved", "show unlimited approvals")
|
|
72
|
+
- "scan" — run a token security scan (e.g. "is this token safe?", "scan 0x...", "check if 0x... is a honeypot", "is 0x... a rug pull?", "security check on 0x...")
|
|
72
73
|
- "unknown" — can't determine what the user wants
|
|
73
74
|
|
|
74
75
|
CASINO GAMES:
|
|
@@ -161,7 +162,8 @@ COMMAND MAPPING:
|
|
|
161
162
|
- gas → darksol gas <chain>
|
|
162
163
|
- cards → darksol cards order -p <provider> -a <amount> -e <email> --ticker <crypto>
|
|
163
164
|
- casino → darksol casino bet <game> -c <choice>
|
|
164
|
-
- analyze → darksol ai analyze <token
|
|
165
|
+
- analyze → darksol ai analyze <token>
|
|
166
|
+
- scan → darksol scan <address> --chain <chain>`;
|
|
165
167
|
|
|
166
168
|
// ──────────────────────────────────────────────────
|
|
167
169
|
// INTENT PARSER
|
|
@@ -306,7 +308,7 @@ export async function startChat(opts = {}) {
|
|
|
306
308
|
}
|
|
307
309
|
|
|
308
310
|
// Try to detect actionable intent
|
|
309
|
-
const actionKeywords = /\b(swap|send|transfer|buy|sell|snipe|dca|price|balance|gas|card|cards|order|prepaid|visa|mastercard|casino|bet|coinflip|coin|flip|dice|slots|hilo|gamble|play)\b/i;
|
|
311
|
+
const actionKeywords = /\b(swap|send|transfer|buy|sell|snipe|dca|price|balance|gas|card|cards|order|prepaid|visa|mastercard|casino|bet|coinflip|coin|flip|dice|slots|hilo|gamble|play|scan|honeypot|rug|rugpull|safe|scam|security)\b/i;
|
|
310
312
|
const isActionable = actionKeywords.test(input);
|
|
311
313
|
|
|
312
314
|
let result;
|
|
@@ -681,6 +683,19 @@ export async function executeIntent(intent, opts = {}) {
|
|
|
681
683
|
return { success: false, reason: 'Tell me which token to look at.' };
|
|
682
684
|
}
|
|
683
685
|
|
|
686
|
+
case 'scan': {
|
|
687
|
+
const token = intent.token || intent.tokenOut || intent.tokenIn;
|
|
688
|
+
if (!token || !token.startsWith('0x')) {
|
|
689
|
+
info('I need a token contract address to scan. Example: "scan 0x1234..."');
|
|
690
|
+
return { success: false, reason: 'Provide a contract address to scan.' };
|
|
691
|
+
}
|
|
692
|
+
const { scanToken: doScan, displayScanResult } = await import('../services/scanner.js');
|
|
693
|
+
const scanChain = intent.chain || getConfig('chain') || 'base';
|
|
694
|
+
const scanResult = await doScan(token, scanChain, { quick: false });
|
|
695
|
+
displayScanResult(scanResult);
|
|
696
|
+
return { success: true, action: 'scan' };
|
|
697
|
+
}
|
|
698
|
+
|
|
684
699
|
case 'approvals': {
|
|
685
700
|
const { listApprovals, revokeApproval } = await import('../services/approvals.js');
|
|
686
701
|
const chain = intent.chain || opts.chain || 'base';
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
import { getServiceURL } from '../config/store.js';
|
|
3
|
+
import { theme } from '../ui/theme.js';
|
|
4
|
+
import { spinner, kvDisplay, success, error, info, table } from '../ui/components.js';
|
|
5
|
+
import { showSection } from '../ui/banner.js';
|
|
6
|
+
|
|
7
|
+
// Service definitions — each has a name, URL resolver, and endpoint to ping
|
|
8
|
+
const SERVICES = [
|
|
9
|
+
{
|
|
10
|
+
name: 'Facilitator',
|
|
11
|
+
url: () => (getServiceURL('facilitator') || 'https://facilitator.darksol.net') + '/',
|
|
12
|
+
desc: 'x402 payment facilitator',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: 'Casino',
|
|
16
|
+
url: () => (getServiceURL('casino') || 'https://casino.darksol.net') + '/api/stats',
|
|
17
|
+
desc: 'On-chain casino',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'Oracle',
|
|
21
|
+
url: () => (getServiceURL('oracle') || 'https://acp.darksol.net/api/oracle') + '/health',
|
|
22
|
+
desc: 'Random oracle (x402)',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'Cards',
|
|
26
|
+
url: () => (getServiceURL('cards') || 'https://acp.darksol.net') + '/api/cards/catalog',
|
|
27
|
+
desc: 'Prepaid crypto cards',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'LI.FI',
|
|
31
|
+
url: () => 'https://li.quest/v1/status',
|
|
32
|
+
desc: 'Cross-chain swaps & bridges',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'Agent Signer',
|
|
36
|
+
url: () => 'http://127.0.0.1:18790/status',
|
|
37
|
+
desc: 'Local signing proxy',
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const TIMEOUT_MS = 5000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Ping a single service endpoint.
|
|
45
|
+
* Returns { name, url, status: 'up'|'down'|'timeout', responseMs, error? }
|
|
46
|
+
*/
|
|
47
|
+
async function pingService(service) {
|
|
48
|
+
const url = service.url();
|
|
49
|
+
const start = Date.now();
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
54
|
+
|
|
55
|
+
const resp = await fetch(url, { signal: controller.signal });
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
|
|
58
|
+
const responseMs = Date.now() - start;
|
|
59
|
+
|
|
60
|
+
if (resp.ok || resp.status < 500) {
|
|
61
|
+
return { name: service.name, url, status: 'up', responseMs, desc: service.desc };
|
|
62
|
+
}
|
|
63
|
+
return { name: service.name, url, status: 'down', responseMs, desc: service.desc, error: `HTTP ${resp.status}` };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const responseMs = Date.now() - start;
|
|
66
|
+
if (err.name === 'AbortError') {
|
|
67
|
+
return { name: service.name, url, status: 'timeout', responseMs, desc: service.desc, error: 'Timed out' };
|
|
68
|
+
}
|
|
69
|
+
// Connection refused, DNS failure, etc.
|
|
70
|
+
const msg = err.code === 'ECONNREFUSED' ? 'Connection refused' : (err.message || 'Unknown error');
|
|
71
|
+
return { name: service.name, url, status: 'down', responseMs, desc: service.desc, error: msg };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check health of all configured services.
|
|
77
|
+
* Returns array of results.
|
|
78
|
+
*/
|
|
79
|
+
export async function checkHealth() {
|
|
80
|
+
return Promise.all(SERVICES.map(s => pingService(s)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* CLI handler — check all services and display results.
|
|
85
|
+
*/
|
|
86
|
+
export async function healthCommand() {
|
|
87
|
+
showSection('SERVICE HEALTH CHECK 🏥');
|
|
88
|
+
const spin = spinner('Checking all services...').start();
|
|
89
|
+
|
|
90
|
+
const results = await checkHealth();
|
|
91
|
+
spin.stop();
|
|
92
|
+
|
|
93
|
+
// Status indicators
|
|
94
|
+
const statusIcon = (s) => {
|
|
95
|
+
if (s === 'up') return theme.success('● UP');
|
|
96
|
+
if (s === 'timeout') return theme.warning('◐ TIMEOUT');
|
|
97
|
+
return theme.error('○ DOWN');
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const latencyColor = (ms) => {
|
|
101
|
+
if (ms < 300) return theme.success(`${ms}ms`);
|
|
102
|
+
if (ms < 1000) return theme.warning(`${ms}ms`);
|
|
103
|
+
return theme.error(`${ms}ms`);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Display table
|
|
107
|
+
const headers = ['Service', 'Status', 'Latency', 'Details'];
|
|
108
|
+
const rows = results.map(r => [
|
|
109
|
+
theme.bright(r.name),
|
|
110
|
+
statusIcon(r.status),
|
|
111
|
+
latencyColor(r.responseMs),
|
|
112
|
+
r.status === 'up' ? theme.dim(r.desc) : theme.error(r.error || ''),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
console.log('');
|
|
116
|
+
table(headers, rows);
|
|
117
|
+
|
|
118
|
+
// Summary
|
|
119
|
+
const healthy = results.filter(r => r.status === 'up').length;
|
|
120
|
+
const total = results.length;
|
|
121
|
+
console.log('');
|
|
122
|
+
|
|
123
|
+
if (healthy === total) {
|
|
124
|
+
success(`${healthy}/${total} services healthy`);
|
|
125
|
+
} else if (healthy > 0) {
|
|
126
|
+
info(`${healthy}/${total} services healthy`);
|
|
127
|
+
} else {
|
|
128
|
+
error(`${healthy}/${total} services healthy`);
|
|
129
|
+
}
|
|
130
|
+
console.log('');
|
|
131
|
+
}
|
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
import { ethers } from 'ethers';
|
|
2
|
+
import { getRPC, getConfig } from '../config/store.js';
|
|
3
|
+
import { getApiKey } from '../config/keys.js';
|
|
4
|
+
import { theme } from '../ui/theme.js';
|
|
5
|
+
import { spinner, kvDisplay, success, error, warn, info, formatAddress } from '../ui/components.js';
|
|
6
|
+
import { showSection } from '../ui/banner.js';
|
|
7
|
+
|
|
8
|
+
// ──────────────────────────────────────────────────
|
|
9
|
+
// CONSTANTS
|
|
10
|
+
// ──────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const DEAD_ADDRESSES = [
|
|
13
|
+
'0x0000000000000000000000000000000000000000',
|
|
14
|
+
'0x000000000000000000000000000000000000dEaD',
|
|
15
|
+
'0x0000000000000000000000000000000000000001',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const EXPLORER_APIS = {
|
|
19
|
+
base: 'https://api.basescan.org',
|
|
20
|
+
ethereum: 'https://api.etherscan.io',
|
|
21
|
+
arbitrum: 'https://api.arbiscan.io',
|
|
22
|
+
optimism: 'https://api-optimistic.etherscan.io',
|
|
23
|
+
polygon: 'https://api.polygonscan.com',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const EXPLORER_NAMES = {
|
|
27
|
+
base: 'Basescan',
|
|
28
|
+
ethereum: 'Etherscan',
|
|
29
|
+
arbitrum: 'Arbiscan',
|
|
30
|
+
optimism: 'Optimistic Etherscan',
|
|
31
|
+
polygon: 'Polygonscan',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const UNISWAP_V3_FACTORY = {
|
|
35
|
+
base: '0x33128a8fC17869897dcE68Ed026d694621f6FDfD',
|
|
36
|
+
ethereum: '0x1F98431c8aD98523631AE4a59f267346ea31F984',
|
|
37
|
+
arbitrum: '0x1F98431c8aD98523631AE4a59f267346ea31F984',
|
|
38
|
+
optimism: '0x1F98431c8aD98523631AE4a59f267346ea31F984',
|
|
39
|
+
polygon: '0x1F98431c8aD98523631AE4a59f267346ea31F984',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const UNISWAP_V3_QUOTER = {
|
|
43
|
+
base: '0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a',
|
|
44
|
+
ethereum: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
|
|
45
|
+
arbitrum: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
|
|
46
|
+
optimism: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
|
|
47
|
+
polygon: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const WETH = {
|
|
51
|
+
base: '0x4200000000000000000000000000000000000006',
|
|
52
|
+
ethereum: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
|
53
|
+
arbitrum: '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1',
|
|
54
|
+
optimism: '0x4200000000000000000000000000000000000006',
|
|
55
|
+
polygon: '0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270', // WMATIC
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const USDC = {
|
|
59
|
+
base: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
60
|
+
ethereum: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
|
|
61
|
+
arbitrum: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
|
|
62
|
+
optimism: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85',
|
|
63
|
+
polygon: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// EIP-1967 implementation slot
|
|
67
|
+
const EIP1967_IMPL_SLOT = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc';
|
|
68
|
+
|
|
69
|
+
// Mint function selector: mint(address,uint256)
|
|
70
|
+
const MINT_SELECTOR = '40c10f19';
|
|
71
|
+
|
|
72
|
+
// Common owner() selector
|
|
73
|
+
const OWNER_SELECTOR = '0x8da5cb5b';
|
|
74
|
+
|
|
75
|
+
// ABIs
|
|
76
|
+
const ERC20_ABI = [
|
|
77
|
+
'function name() view returns (string)',
|
|
78
|
+
'function symbol() view returns (string)',
|
|
79
|
+
'function decimals() view returns (uint8)',
|
|
80
|
+
'function totalSupply() view returns (uint256)',
|
|
81
|
+
'function balanceOf(address) view returns (uint256)',
|
|
82
|
+
'function owner() view returns (address)',
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const FACTORY_ABI = [
|
|
86
|
+
'function getPool(address,address,uint24) view returns (address)',
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const POOL_ABI = [
|
|
90
|
+
'function slot0() view returns (uint160,int24,uint16,uint16,uint16,uint8,bool)',
|
|
91
|
+
'function liquidity() view returns (uint128)',
|
|
92
|
+
'function token0() view returns (address)',
|
|
93
|
+
'function token1() view returns (address)',
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const QUOTER_ABI = [
|
|
97
|
+
'function quoteExactInputSingle((address tokenIn, address tokenOut, uint256 amountIn, uint24 fee, uint160 sqrtPriceLimitX96)) returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)',
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// ──────────────────────────────────────────────────
|
|
101
|
+
// RISK SCORING
|
|
102
|
+
// ──────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export const CHECK_STATUS = {
|
|
105
|
+
PASS: 'pass',
|
|
106
|
+
WARN: 'warn',
|
|
107
|
+
FAIL: 'fail',
|
|
108
|
+
ERROR: 'error',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Calculate overall risk from individual check results
|
|
113
|
+
* @param {Array<{status: string}>} checks - Array of check results
|
|
114
|
+
* @returns {{ level: string, score: number, failed: number, warned: number, passed: number }}
|
|
115
|
+
*/
|
|
116
|
+
export function calculateRisk(checks) {
|
|
117
|
+
let failed = 0;
|
|
118
|
+
let warned = 0;
|
|
119
|
+
let passed = 0;
|
|
120
|
+
|
|
121
|
+
for (const check of checks) {
|
|
122
|
+
if (check.status === CHECK_STATUS.FAIL) failed++;
|
|
123
|
+
else if (check.status === CHECK_STATUS.WARN) warned++;
|
|
124
|
+
else if (check.status === CHECK_STATUS.PASS) passed++;
|
|
125
|
+
// errors count as warnings
|
|
126
|
+
else if (check.status === CHECK_STATUS.ERROR) warned++;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let level;
|
|
130
|
+
if (failed >= 3) level = 'CRITICAL';
|
|
131
|
+
else if (failed >= 2) level = 'HIGH';
|
|
132
|
+
else if (failed >= 1 || warned >= 3) level = 'HIGH';
|
|
133
|
+
else if (warned >= 2) level = 'MEDIUM';
|
|
134
|
+
else if (warned >= 1) level = 'LOW';
|
|
135
|
+
else level = 'LOW';
|
|
136
|
+
|
|
137
|
+
const total = checks.length;
|
|
138
|
+
const score = total > 0 ? Math.round(((passed / total) * 100)) : 0;
|
|
139
|
+
|
|
140
|
+
return { level, score, failed, warned, passed, total };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get the recommendation text for a risk level
|
|
145
|
+
*/
|
|
146
|
+
export function getRecommendation(risk, checks) {
|
|
147
|
+
const honeypot = checks.find(c => c.id === 'honeypot');
|
|
148
|
+
const liquidity = checks.find(c => c.id === 'liquidity');
|
|
149
|
+
|
|
150
|
+
if (risk.level === 'CRITICAL') {
|
|
151
|
+
return 'DO NOT TRADE — multiple critical issues detected';
|
|
152
|
+
}
|
|
153
|
+
if (honeypot?.status === CHECK_STATUS.FAIL) {
|
|
154
|
+
return 'DO NOT TRADE — honeypot characteristics detected';
|
|
155
|
+
}
|
|
156
|
+
if (liquidity?.status === CHECK_STATUS.FAIL) {
|
|
157
|
+
return 'DO NOT TRADE — no liquidity available';
|
|
158
|
+
}
|
|
159
|
+
if (risk.level === 'HIGH') {
|
|
160
|
+
return 'EXTREME CAUTION — significant red flags found';
|
|
161
|
+
}
|
|
162
|
+
if (risk.level === 'MEDIUM') {
|
|
163
|
+
return 'PROCEED WITH CAUTION — some concerns identified';
|
|
164
|
+
}
|
|
165
|
+
return 'Lower risk — standard checks passed';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ──────────────────────────────────────────────────
|
|
169
|
+
// INDIVIDUAL CHECKS
|
|
170
|
+
// ──────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get token basic info: name, symbol, decimals, totalSupply
|
|
174
|
+
*/
|
|
175
|
+
export async function getTokenInfo(address, provider) {
|
|
176
|
+
const contract = new ethers.Contract(address, ERC20_ABI, provider);
|
|
177
|
+
|
|
178
|
+
const [name, symbol, decimals, totalSupply] = await Promise.all([
|
|
179
|
+
contract.name().catch(() => 'Unknown'),
|
|
180
|
+
contract.symbol().catch(() => '???'),
|
|
181
|
+
contract.decimals().catch(() => 18),
|
|
182
|
+
contract.totalSupply().catch(() => 0n),
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
return { name, symbol, decimals: Number(decimals), totalSupply };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get the deployer of a contract via explorer API
|
|
190
|
+
*/
|
|
191
|
+
async function getDeployer(address, chain) {
|
|
192
|
+
const apiKey = getApiKey('etherscan');
|
|
193
|
+
const baseUrl = EXPLORER_APIS[chain];
|
|
194
|
+
if (!baseUrl) return null;
|
|
195
|
+
|
|
196
|
+
const url = `${baseUrl}/api?module=contract&action=getcontractcreation&contractaddresses=${address}${apiKey ? `&apikey=${apiKey}` : ''}`;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const resp = await fetchWithTimeout(url, 8000);
|
|
200
|
+
const data = await resp.json();
|
|
201
|
+
if (data.status === '1' && data.result?.length > 0) {
|
|
202
|
+
return data.result[0].contractCreator;
|
|
203
|
+
}
|
|
204
|
+
} catch {}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check 1: Contract verification status
|
|
210
|
+
*/
|
|
211
|
+
export async function checkVerification(address, chain) {
|
|
212
|
+
const apiKey = getApiKey('etherscan');
|
|
213
|
+
const baseUrl = EXPLORER_APIS[chain];
|
|
214
|
+
const explorerName = EXPLORER_NAMES[chain] || 'Explorer';
|
|
215
|
+
|
|
216
|
+
if (!baseUrl) {
|
|
217
|
+
return {
|
|
218
|
+
id: 'verification',
|
|
219
|
+
label: 'Contract Verified',
|
|
220
|
+
status: CHECK_STATUS.ERROR,
|
|
221
|
+
detail: `No explorer API for ${chain}`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const url = `${baseUrl}/api?module=contract&action=getsourcecode&address=${address}${apiKey ? `&apikey=${apiKey}` : ''}`;
|
|
227
|
+
const resp = await fetchWithTimeout(url, 8000);
|
|
228
|
+
const data = await resp.json();
|
|
229
|
+
|
|
230
|
+
if (data.status === '1' && data.result?.[0]) {
|
|
231
|
+
const src = data.result[0];
|
|
232
|
+
if (src.SourceCode && src.SourceCode !== '') {
|
|
233
|
+
return {
|
|
234
|
+
id: 'verification',
|
|
235
|
+
label: 'Contract Verified',
|
|
236
|
+
status: CHECK_STATUS.PASS,
|
|
237
|
+
detail: `Source code visible on ${explorerName}`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
id: 'verification',
|
|
244
|
+
label: 'Contract Verified',
|
|
245
|
+
status: CHECK_STATUS.WARN,
|
|
246
|
+
detail: `Not verified on ${explorerName}`,
|
|
247
|
+
};
|
|
248
|
+
} catch (err) {
|
|
249
|
+
return {
|
|
250
|
+
id: 'verification',
|
|
251
|
+
label: 'Contract Verified',
|
|
252
|
+
status: CHECK_STATUS.ERROR,
|
|
253
|
+
detail: `Explorer API unreachable: ${err.message}`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Check 2: Ownership status
|
|
260
|
+
*/
|
|
261
|
+
export async function checkOwnership(address, provider) {
|
|
262
|
+
try {
|
|
263
|
+
const contract = new ethers.Contract(address, ERC20_ABI, provider);
|
|
264
|
+
const owner = await contract.owner();
|
|
265
|
+
|
|
266
|
+
if (DEAD_ADDRESSES.includes(owner.toLowerCase())) {
|
|
267
|
+
return {
|
|
268
|
+
id: 'ownership',
|
|
269
|
+
label: 'Ownership Renounced',
|
|
270
|
+
status: CHECK_STATUS.PASS,
|
|
271
|
+
detail: `Owner set to ${formatAddress(owner)}`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
id: 'ownership',
|
|
277
|
+
label: 'Ownership Active',
|
|
278
|
+
status: CHECK_STATUS.WARN,
|
|
279
|
+
detail: `Owner: ${formatAddress(owner)}`,
|
|
280
|
+
};
|
|
281
|
+
} catch {
|
|
282
|
+
// No owner() function — could mean no ownership pattern (good) or non-standard
|
|
283
|
+
return {
|
|
284
|
+
id: 'ownership',
|
|
285
|
+
label: 'No Owner Function',
|
|
286
|
+
status: CHECK_STATUS.PASS,
|
|
287
|
+
detail: 'Contract has no owner() — likely ownerless',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Check 3: Honeypot detection via Uniswap V3 buy/sell simulation
|
|
294
|
+
*/
|
|
295
|
+
export async function checkHoneypot(address, chain, provider, opts = {}) {
|
|
296
|
+
const quoter = UNISWAP_V3_QUOTER[chain];
|
|
297
|
+
const weth = WETH[chain];
|
|
298
|
+
if (!quoter || !weth) {
|
|
299
|
+
return {
|
|
300
|
+
id: 'honeypot',
|
|
301
|
+
label: 'Honeypot Detection',
|
|
302
|
+
status: CHECK_STATUS.ERROR,
|
|
303
|
+
detail: `No quoter available for ${chain}`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const quoterContract = new ethers.Contract(quoter, QUOTER_ABI, provider);
|
|
309
|
+
const buyAmount = ethers.parseEther('0.01'); // simulate 0.01 ETH buy
|
|
310
|
+
|
|
311
|
+
// Step 1: Simulate buy (WETH → token)
|
|
312
|
+
let buyResult;
|
|
313
|
+
try {
|
|
314
|
+
buyResult = await quoterContract.quoteExactInputSingle.staticCall({
|
|
315
|
+
tokenIn: weth,
|
|
316
|
+
tokenOut: address,
|
|
317
|
+
amountIn: buyAmount,
|
|
318
|
+
fee: 3000,
|
|
319
|
+
sqrtPriceLimitX96: 0n,
|
|
320
|
+
});
|
|
321
|
+
} catch {
|
|
322
|
+
// Try 10000 fee tier
|
|
323
|
+
try {
|
|
324
|
+
buyResult = await quoterContract.quoteExactInputSingle.staticCall({
|
|
325
|
+
tokenIn: weth,
|
|
326
|
+
tokenOut: address,
|
|
327
|
+
amountIn: buyAmount,
|
|
328
|
+
fee: 10000,
|
|
329
|
+
sqrtPriceLimitX96: 0n,
|
|
330
|
+
});
|
|
331
|
+
} catch {
|
|
332
|
+
return {
|
|
333
|
+
id: 'honeypot',
|
|
334
|
+
label: 'Honeypot Detection',
|
|
335
|
+
status: CHECK_STATUS.FAIL,
|
|
336
|
+
detail: 'Buy simulation failed — no liquidity pool or blocked',
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const tokensReceived = buyResult[0] || buyResult;
|
|
342
|
+
|
|
343
|
+
// Step 2: Simulate sell (token → WETH)
|
|
344
|
+
let sellResult;
|
|
345
|
+
try {
|
|
346
|
+
sellResult = await quoterContract.quoteExactInputSingle.staticCall({
|
|
347
|
+
tokenIn: address,
|
|
348
|
+
tokenOut: weth,
|
|
349
|
+
amountIn: tokensReceived,
|
|
350
|
+
fee: 3000,
|
|
351
|
+
sqrtPriceLimitX96: 0n,
|
|
352
|
+
});
|
|
353
|
+
} catch {
|
|
354
|
+
try {
|
|
355
|
+
sellResult = await quoterContract.quoteExactInputSingle.staticCall({
|
|
356
|
+
tokenIn: address,
|
|
357
|
+
tokenOut: weth,
|
|
358
|
+
amountIn: tokensReceived,
|
|
359
|
+
fee: 10000,
|
|
360
|
+
sqrtPriceLimitX96: 0n,
|
|
361
|
+
});
|
|
362
|
+
} catch {
|
|
363
|
+
return {
|
|
364
|
+
id: 'honeypot',
|
|
365
|
+
label: 'Honeypot Risk',
|
|
366
|
+
status: CHECK_STATUS.FAIL,
|
|
367
|
+
detail: 'Sell simulation failed — potential honeypot (sells blocked)',
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const ethBack = sellResult[0] || sellResult;
|
|
373
|
+
|
|
374
|
+
// Calculate tax
|
|
375
|
+
const taxPercent = Number(((buyAmount - ethBack) * 10000n) / buyAmount) / 100;
|
|
376
|
+
|
|
377
|
+
if (taxPercent > 50) {
|
|
378
|
+
return {
|
|
379
|
+
id: 'honeypot',
|
|
380
|
+
label: 'Honeypot Risk',
|
|
381
|
+
status: CHECK_STATUS.FAIL,
|
|
382
|
+
detail: `Sell simulation shows ${taxPercent.toFixed(1)}% tax — likely honeypot`,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (taxPercent > 10) {
|
|
387
|
+
return {
|
|
388
|
+
id: 'honeypot',
|
|
389
|
+
label: 'High Tax',
|
|
390
|
+
status: CHECK_STATUS.WARN,
|
|
391
|
+
detail: `Buy+sell roundtrip tax: ${taxPercent.toFixed(1)}%`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
id: 'honeypot',
|
|
397
|
+
label: 'Not a Honeypot',
|
|
398
|
+
status: CHECK_STATUS.PASS,
|
|
399
|
+
detail: `Buy+sell roundtrip tax: ${taxPercent.toFixed(1)}%`,
|
|
400
|
+
};
|
|
401
|
+
} catch (err) {
|
|
402
|
+
return {
|
|
403
|
+
id: 'honeypot',
|
|
404
|
+
label: 'Honeypot Detection',
|
|
405
|
+
status: CHECK_STATUS.ERROR,
|
|
406
|
+
detail: `Simulation error: ${err.message?.slice(0, 80)}`,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Check 4: Liquidity analysis
|
|
413
|
+
*/
|
|
414
|
+
export async function checkLiquidity(address, chain, provider) {
|
|
415
|
+
const factory = UNISWAP_V3_FACTORY[chain];
|
|
416
|
+
const weth = WETH[chain];
|
|
417
|
+
const usdc = USDC[chain];
|
|
418
|
+
if (!factory) {
|
|
419
|
+
return {
|
|
420
|
+
id: 'liquidity',
|
|
421
|
+
label: 'Liquidity Analysis',
|
|
422
|
+
status: CHECK_STATUS.ERROR,
|
|
423
|
+
detail: `No factory address for ${chain}`,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
const factoryContract = new ethers.Contract(factory, FACTORY_ABI, provider);
|
|
429
|
+
const feeTiers = [3000, 10000, 500];
|
|
430
|
+
|
|
431
|
+
// Try to find a pool (token/WETH or token/USDC)
|
|
432
|
+
let poolAddress = ethers.ZeroAddress;
|
|
433
|
+
let pairLabel = '';
|
|
434
|
+
|
|
435
|
+
for (const pairedToken of [weth, usdc]) {
|
|
436
|
+
for (const fee of feeTiers) {
|
|
437
|
+
try {
|
|
438
|
+
const addr = await factoryContract.getPool(address, pairedToken, fee);
|
|
439
|
+
if (addr && addr !== ethers.ZeroAddress) {
|
|
440
|
+
poolAddress = addr;
|
|
441
|
+
pairLabel = pairedToken === weth ? 'WETH' : 'USDC';
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
} catch {}
|
|
445
|
+
}
|
|
446
|
+
if (poolAddress !== ethers.ZeroAddress) break;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (poolAddress === ethers.ZeroAddress) {
|
|
450
|
+
return {
|
|
451
|
+
id: 'liquidity',
|
|
452
|
+
label: 'No Liquidity Pool',
|
|
453
|
+
status: CHECK_STATUS.FAIL,
|
|
454
|
+
detail: 'No Uniswap V3 pool found (WETH or USDC pair)',
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Get pool liquidity
|
|
459
|
+
const pool = new ethers.Contract(poolAddress, POOL_ABI, provider);
|
|
460
|
+
const liquidity = await pool.liquidity();
|
|
461
|
+
|
|
462
|
+
// Rough USD estimate — liquidity units are abstract, but we can give a relative sense
|
|
463
|
+
const liqNum = Number(liquidity);
|
|
464
|
+
let liqLabel;
|
|
465
|
+
if (liqNum === 0) {
|
|
466
|
+
return {
|
|
467
|
+
id: 'liquidity',
|
|
468
|
+
label: 'Empty Pool',
|
|
469
|
+
status: CHECK_STATUS.FAIL,
|
|
470
|
+
detail: `Pool exists (${pairLabel} pair) but has zero liquidity`,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// For a very rough estimate, check WETH balance in the pool
|
|
475
|
+
let usdEstimate = null;
|
|
476
|
+
try {
|
|
477
|
+
const wethContract = new ethers.Contract(weth, ['function balanceOf(address) view returns (uint256)'], provider);
|
|
478
|
+
const wethBal = await wethContract.balanceOf(poolAddress);
|
|
479
|
+
const ethInPool = Number(ethers.formatEther(wethBal));
|
|
480
|
+
// Rough ETH price estimate
|
|
481
|
+
usdEstimate = ethInPool * 2500; // conservative ETH estimate
|
|
482
|
+
// Double it since pool has two sides
|
|
483
|
+
usdEstimate *= 2;
|
|
484
|
+
} catch {}
|
|
485
|
+
|
|
486
|
+
if (usdEstimate !== null) {
|
|
487
|
+
if (usdEstimate < 1000) {
|
|
488
|
+
return {
|
|
489
|
+
id: 'liquidity',
|
|
490
|
+
label: 'Very Low Liquidity',
|
|
491
|
+
status: CHECK_STATUS.FAIL,
|
|
492
|
+
detail: `~$${formatNumber(usdEstimate)} in ${pairLabel} pool — extremely thin`,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
if (usdEstimate < 50000) {
|
|
496
|
+
return {
|
|
497
|
+
id: 'liquidity',
|
|
498
|
+
label: 'Low Liquidity',
|
|
499
|
+
status: CHECK_STATUS.WARN,
|
|
500
|
+
detail: `~$${formatNumber(usdEstimate)} in ${pairLabel} pool`,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
id: 'liquidity',
|
|
505
|
+
label: 'Liquidity OK',
|
|
506
|
+
status: CHECK_STATUS.PASS,
|
|
507
|
+
detail: `~$${formatNumber(usdEstimate)} in ${pairLabel} pool`,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Fallback: just report pool exists with liquidity
|
|
512
|
+
return {
|
|
513
|
+
id: 'liquidity',
|
|
514
|
+
label: 'Pool Found',
|
|
515
|
+
status: CHECK_STATUS.PASS,
|
|
516
|
+
detail: `${pairLabel} pool at ${formatAddress(poolAddress)}`,
|
|
517
|
+
};
|
|
518
|
+
} catch (err) {
|
|
519
|
+
return {
|
|
520
|
+
id: 'liquidity',
|
|
521
|
+
label: 'Liquidity Analysis',
|
|
522
|
+
status: CHECK_STATUS.ERROR,
|
|
523
|
+
detail: `Check failed: ${err.message?.slice(0, 80)}`,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Check 5: Holder concentration (top holders via explorer API)
|
|
530
|
+
*/
|
|
531
|
+
export async function checkHolderConcentration(address, chain, tokenInfo) {
|
|
532
|
+
const apiKey = getApiKey('etherscan');
|
|
533
|
+
const baseUrl = EXPLORER_APIS[chain];
|
|
534
|
+
|
|
535
|
+
if (!baseUrl) {
|
|
536
|
+
return {
|
|
537
|
+
id: 'holders',
|
|
538
|
+
label: 'Holder Concentration',
|
|
539
|
+
status: CHECK_STATUS.ERROR,
|
|
540
|
+
detail: `No explorer API for ${chain}`,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
// Get top token holders via explorer API
|
|
546
|
+
const url = `${baseUrl}/api?module=token&action=tokenholderlist&contractaddress=${address}&page=1&offset=10${apiKey ? `&apikey=${apiKey}` : ''}`;
|
|
547
|
+
const resp = await fetchWithTimeout(url, 8000);
|
|
548
|
+
const data = await resp.json();
|
|
549
|
+
|
|
550
|
+
if (data.status !== '1' || !data.result?.length) {
|
|
551
|
+
// Fallback: try to check deployer balance
|
|
552
|
+
return {
|
|
553
|
+
id: 'holders',
|
|
554
|
+
label: 'Holder Concentration',
|
|
555
|
+
status: CHECK_STATUS.ERROR,
|
|
556
|
+
detail: 'Holder data unavailable (may require pro API)',
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const holders = data.result;
|
|
561
|
+
const totalSupply = tokenInfo.totalSupply;
|
|
562
|
+
|
|
563
|
+
if (totalSupply === 0n) {
|
|
564
|
+
return {
|
|
565
|
+
id: 'holders',
|
|
566
|
+
label: 'Holder Concentration',
|
|
567
|
+
status: CHECK_STATUS.ERROR,
|
|
568
|
+
detail: 'Cannot calculate — zero total supply',
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Top holder percentage
|
|
573
|
+
const topBalance = BigInt(holders[0].TokenHolderQuantity || '0');
|
|
574
|
+
const topPercent = Number((topBalance * 10000n) / totalSupply) / 100;
|
|
575
|
+
const topAddr = holders[0].TokenHolderAddress;
|
|
576
|
+
const isDeadAddr = DEAD_ADDRESSES.includes(topAddr?.toLowerCase());
|
|
577
|
+
|
|
578
|
+
// Top 5 combined (excluding dead addresses)
|
|
579
|
+
let top5Total = 0n;
|
|
580
|
+
for (const h of holders.slice(0, 5)) {
|
|
581
|
+
if (!DEAD_ADDRESSES.includes(h.TokenHolderAddress?.toLowerCase())) {
|
|
582
|
+
top5Total += BigInt(h.TokenHolderQuantity || '0');
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const top5Percent = Number((top5Total * 10000n) / totalSupply) / 100;
|
|
586
|
+
|
|
587
|
+
if (isDeadAddr && topPercent > 20) {
|
|
588
|
+
// Top holder is dead address (burned tokens) — check next
|
|
589
|
+
const nextBalance = holders[1] ? BigInt(holders[1].TokenHolderQuantity || '0') : 0n;
|
|
590
|
+
const nextPercent = Number((nextBalance * 10000n) / totalSupply) / 100;
|
|
591
|
+
|
|
592
|
+
if (nextPercent > 20) {
|
|
593
|
+
return {
|
|
594
|
+
id: 'holders',
|
|
595
|
+
label: 'Holder Concentration',
|
|
596
|
+
status: CHECK_STATUS.WARN,
|
|
597
|
+
detail: `Top wallet holds ${nextPercent.toFixed(1)}% (${topPercent.toFixed(1)}% burned)`,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
return {
|
|
601
|
+
id: 'holders',
|
|
602
|
+
label: 'Distribution OK',
|
|
603
|
+
status: CHECK_STATUS.PASS,
|
|
604
|
+
detail: `${topPercent.toFixed(1)}% burned, top active wallet: ${nextPercent.toFixed(1)}%`,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (topPercent > 30) {
|
|
609
|
+
return {
|
|
610
|
+
id: 'holders',
|
|
611
|
+
label: 'High Concentration',
|
|
612
|
+
status: CHECK_STATUS.FAIL,
|
|
613
|
+
detail: `Top wallet holds ${topPercent.toFixed(1)}% of supply`,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (topPercent > 10) {
|
|
618
|
+
return {
|
|
619
|
+
id: 'holders',
|
|
620
|
+
label: 'Holder Concentration',
|
|
621
|
+
status: CHECK_STATUS.WARN,
|
|
622
|
+
detail: `Top wallet holds ${topPercent.toFixed(1)}% of supply`,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
id: 'holders',
|
|
628
|
+
label: 'Distribution OK',
|
|
629
|
+
status: CHECK_STATUS.PASS,
|
|
630
|
+
detail: `Top wallet: ${topPercent.toFixed(1)}%, top 5: ${top5Percent.toFixed(1)}%`,
|
|
631
|
+
};
|
|
632
|
+
} catch (err) {
|
|
633
|
+
return {
|
|
634
|
+
id: 'holders',
|
|
635
|
+
label: 'Holder Concentration',
|
|
636
|
+
status: CHECK_STATUS.ERROR,
|
|
637
|
+
detail: `Check failed: ${err.message?.slice(0, 80)}`,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Check 6: Proxy detection (EIP-1967)
|
|
644
|
+
*/
|
|
645
|
+
export async function checkProxy(address, provider) {
|
|
646
|
+
try {
|
|
647
|
+
const implSlot = await provider.getStorage(address, EIP1967_IMPL_SLOT);
|
|
648
|
+
|
|
649
|
+
// Non-zero slot means it's a proxy
|
|
650
|
+
if (implSlot && implSlot !== ethers.ZeroHash) {
|
|
651
|
+
const implAddr = '0x' + implSlot.slice(26); // last 20 bytes
|
|
652
|
+
return {
|
|
653
|
+
id: 'proxy',
|
|
654
|
+
label: 'Proxy Detected',
|
|
655
|
+
status: CHECK_STATUS.WARN,
|
|
656
|
+
detail: `EIP-1967 proxy → impl: ${formatAddress(implAddr)}`,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return {
|
|
661
|
+
id: 'proxy',
|
|
662
|
+
label: 'Not a Proxy',
|
|
663
|
+
status: CHECK_STATUS.PASS,
|
|
664
|
+
detail: 'No EIP-1967 proxy pattern detected',
|
|
665
|
+
};
|
|
666
|
+
} catch (err) {
|
|
667
|
+
return {
|
|
668
|
+
id: 'proxy',
|
|
669
|
+
label: 'Proxy Detection',
|
|
670
|
+
status: CHECK_STATUS.ERROR,
|
|
671
|
+
detail: `Check failed: ${err.message?.slice(0, 80)}`,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Check 7: Mint function detection
|
|
678
|
+
*/
|
|
679
|
+
export async function checkMintFunction(address, provider) {
|
|
680
|
+
try {
|
|
681
|
+
const bytecode = await provider.getCode(address);
|
|
682
|
+
|
|
683
|
+
if (!bytecode || bytecode === '0x') {
|
|
684
|
+
return {
|
|
685
|
+
id: 'mint',
|
|
686
|
+
label: 'Mint Function',
|
|
687
|
+
status: CHECK_STATUS.ERROR,
|
|
688
|
+
detail: 'No bytecode found — not a contract or self-destructed',
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Check for mint(address,uint256) selector in bytecode
|
|
693
|
+
const hasMint = bytecode.toLowerCase().includes(MINT_SELECTOR);
|
|
694
|
+
|
|
695
|
+
if (hasMint) {
|
|
696
|
+
return {
|
|
697
|
+
id: 'mint',
|
|
698
|
+
label: 'Mint Function Found',
|
|
699
|
+
status: CHECK_STATUS.WARN,
|
|
700
|
+
detail: 'Contract has mint capability (0x40c10f19)',
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
id: 'mint',
|
|
706
|
+
label: 'No Mint Function',
|
|
707
|
+
status: CHECK_STATUS.PASS,
|
|
708
|
+
detail: 'No mint(address,uint256) selector in bytecode',
|
|
709
|
+
};
|
|
710
|
+
} catch (err) {
|
|
711
|
+
return {
|
|
712
|
+
id: 'mint',
|
|
713
|
+
label: 'Mint Function',
|
|
714
|
+
status: CHECK_STATUS.ERROR,
|
|
715
|
+
detail: `Check failed: ${err.message?.slice(0, 80)}`,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ──────────────────────────────────────────────────
|
|
721
|
+
// MAIN SCANNER
|
|
722
|
+
// ──────────────────────────────────────────────────
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Run all security checks on a token
|
|
726
|
+
* @param {string} address - Token contract address
|
|
727
|
+
* @param {string} chain - Chain name (base, ethereum, etc.)
|
|
728
|
+
* @param {object} opts - { quick: boolean, json: boolean }
|
|
729
|
+
* @returns {Promise<object>} Full scan result
|
|
730
|
+
*/
|
|
731
|
+
export async function scanToken(address, chain, opts = {}) {
|
|
732
|
+
chain = chain || getConfig('chain') || 'base';
|
|
733
|
+
|
|
734
|
+
// Validate address
|
|
735
|
+
if (!ethers.isAddress(address)) {
|
|
736
|
+
throw new Error(`Invalid address: ${address}`);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const rpcUrl = getRPC(chain);
|
|
740
|
+
if (!rpcUrl) {
|
|
741
|
+
throw new Error(`No RPC configured for chain: ${chain}. Run: darksol config rpc ${chain} <url>`);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
|
745
|
+
|
|
746
|
+
// Verify it's a contract
|
|
747
|
+
const code = await provider.getCode(address);
|
|
748
|
+
if (!code || code === '0x') {
|
|
749
|
+
throw new Error('Address is not a contract (no bytecode). This is an EOA, not a token.');
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Get token info first
|
|
753
|
+
const tokenInfo = await getTokenInfo(address, provider);
|
|
754
|
+
const deployer = await getDeployer(address, chain);
|
|
755
|
+
|
|
756
|
+
// Run checks in parallel (quick mode skips honeypot simulation)
|
|
757
|
+
const checkPromises = [
|
|
758
|
+
checkVerification(address, chain),
|
|
759
|
+
checkOwnership(address, provider),
|
|
760
|
+
checkProxy(address, provider),
|
|
761
|
+
checkMintFunction(address, provider),
|
|
762
|
+
checkLiquidity(address, chain, provider),
|
|
763
|
+
checkHolderConcentration(address, chain, tokenInfo),
|
|
764
|
+
];
|
|
765
|
+
|
|
766
|
+
if (!opts.quick) {
|
|
767
|
+
checkPromises.push(checkHoneypot(address, chain, provider));
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const checks = await Promise.all(checkPromises);
|
|
771
|
+
|
|
772
|
+
// Sort: pass checks first, then warnings, then failures
|
|
773
|
+
const sortOrder = { pass: 0, warn: 1, error: 2, fail: 3 };
|
|
774
|
+
checks.sort((a, b) => (sortOrder[a.status] || 0) - (sortOrder[b.status] || 0));
|
|
775
|
+
|
|
776
|
+
const risk = calculateRisk(checks);
|
|
777
|
+
const recommendation = getRecommendation(risk, checks);
|
|
778
|
+
|
|
779
|
+
return {
|
|
780
|
+
address,
|
|
781
|
+
chain,
|
|
782
|
+
tokenInfo,
|
|
783
|
+
deployer,
|
|
784
|
+
checks,
|
|
785
|
+
risk,
|
|
786
|
+
recommendation,
|
|
787
|
+
timestamp: new Date().toISOString(),
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ──────────────────────────────────────────────────
|
|
792
|
+
// OUTPUT FORMATTING
|
|
793
|
+
// ──────────────────────────────────────────────────
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Display scan results in the terminal
|
|
797
|
+
*/
|
|
798
|
+
export function displayScanResult(result) {
|
|
799
|
+
const { tokenInfo, chain, address, deployer, checks, risk, recommendation } = result;
|
|
800
|
+
|
|
801
|
+
console.log('');
|
|
802
|
+
console.log(theme.gold(' ══ ') + theme.header('TOKEN SECURITY SCAN') + theme.gold(' ══'));
|
|
803
|
+
console.log('');
|
|
804
|
+
|
|
805
|
+
// Token info
|
|
806
|
+
const supplyFormatted = formatSupply(tokenInfo.totalSupply, tokenInfo.decimals);
|
|
807
|
+
kvDisplay([
|
|
808
|
+
['Token', `${tokenInfo.name} (${tokenInfo.symbol})`],
|
|
809
|
+
['Chain', chain.charAt(0).toUpperCase() + chain.slice(1)],
|
|
810
|
+
['Contract', formatAddress(address, 6)],
|
|
811
|
+
['Deployer', deployer ? formatAddress(deployer, 6) : 'Unknown'],
|
|
812
|
+
['Supply', `${supplyFormatted} ${tokenInfo.symbol}`],
|
|
813
|
+
]);
|
|
814
|
+
|
|
815
|
+
// Security checks
|
|
816
|
+
showSection('Security Checks');
|
|
817
|
+
console.log('');
|
|
818
|
+
|
|
819
|
+
for (const check of checks) {
|
|
820
|
+
const icon = getCheckIcon(check.status);
|
|
821
|
+
const labelColor = getCheckLabelColor(check.status);
|
|
822
|
+
const label = labelColor(check.label.padEnd(26));
|
|
823
|
+
const detail = theme.dim(check.detail);
|
|
824
|
+
console.log(` ${icon} ${label} ${detail}`);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Risk score
|
|
828
|
+
showSection('Risk Score');
|
|
829
|
+
console.log('');
|
|
830
|
+
|
|
831
|
+
const riskIcon = getRiskIcon(risk.level);
|
|
832
|
+
const riskColor = getRiskColor(risk.level);
|
|
833
|
+
const riskLine = `${riskIcon} ${riskColor(risk.level + ' RISK')} (${risk.failed} critical, ${risk.warned} warnings, ${risk.passed} passed)`;
|
|
834
|
+
console.log(` ${riskLine}`);
|
|
835
|
+
console.log(` ${theme.dim('Recommendation:')} ${riskColor(recommendation)}`);
|
|
836
|
+
console.log('');
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Return scan result as JSON-friendly object
|
|
841
|
+
*/
|
|
842
|
+
export function scanResultToJSON(result) {
|
|
843
|
+
return {
|
|
844
|
+
token: {
|
|
845
|
+
name: result.tokenInfo.name,
|
|
846
|
+
symbol: result.tokenInfo.symbol,
|
|
847
|
+
decimals: result.tokenInfo.decimals,
|
|
848
|
+
totalSupply: result.tokenInfo.totalSupply.toString(),
|
|
849
|
+
address: result.address,
|
|
850
|
+
deployer: result.deployer,
|
|
851
|
+
},
|
|
852
|
+
chain: result.chain,
|
|
853
|
+
checks: result.checks.map(c => ({
|
|
854
|
+
id: c.id,
|
|
855
|
+
label: c.label,
|
|
856
|
+
status: c.status,
|
|
857
|
+
detail: c.detail,
|
|
858
|
+
})),
|
|
859
|
+
risk: result.risk,
|
|
860
|
+
recommendation: result.recommendation,
|
|
861
|
+
timestamp: result.timestamp,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ──────────────────────────────────────────────────
|
|
866
|
+
// HELPERS
|
|
867
|
+
// ──────────────────────────────────────────────────
|
|
868
|
+
|
|
869
|
+
function fetchWithTimeout(url, timeoutMs = 8000) {
|
|
870
|
+
return Promise.race([
|
|
871
|
+
fetch(url),
|
|
872
|
+
new Promise((_, reject) =>
|
|
873
|
+
setTimeout(() => reject(new Error('Request timed out')), timeoutMs)
|
|
874
|
+
),
|
|
875
|
+
]);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function formatSupply(totalSupply, decimals) {
|
|
879
|
+
if (totalSupply === 0n) return '0';
|
|
880
|
+
const num = Number(ethers.formatUnits(totalSupply, decimals));
|
|
881
|
+
return formatNumber(num);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export function formatNumber(num) {
|
|
885
|
+
if (num >= 1e12) return `${(num / 1e12).toFixed(2)}T`;
|
|
886
|
+
if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`;
|
|
887
|
+
if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`;
|
|
888
|
+
if (num >= 1e3) return `${(num / 1e3).toFixed(2)}K`;
|
|
889
|
+
if (num >= 1) return num.toFixed(2);
|
|
890
|
+
return num.toPrecision(4);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function getCheckIcon(status) {
|
|
894
|
+
switch (status) {
|
|
895
|
+
case CHECK_STATUS.PASS: return theme.success('✅');
|
|
896
|
+
case CHECK_STATUS.WARN: return theme.warning('⚠️ ');
|
|
897
|
+
case CHECK_STATUS.FAIL: return theme.error('❌');
|
|
898
|
+
case CHECK_STATUS.ERROR: return theme.dim('⚙️ ');
|
|
899
|
+
default: return ' ';
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function getCheckLabelColor(status) {
|
|
904
|
+
switch (status) {
|
|
905
|
+
case CHECK_STATUS.PASS: return theme.success;
|
|
906
|
+
case CHECK_STATUS.WARN: return theme.warning;
|
|
907
|
+
case CHECK_STATUS.FAIL: return theme.error;
|
|
908
|
+
case CHECK_STATUS.ERROR: return theme.dim;
|
|
909
|
+
default: return theme.dim;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function getRiskIcon(level) {
|
|
914
|
+
switch (level) {
|
|
915
|
+
case 'LOW': return '🟢';
|
|
916
|
+
case 'MEDIUM': return '🟡';
|
|
917
|
+
case 'HIGH': return '🔴';
|
|
918
|
+
case 'CRITICAL': return '💀';
|
|
919
|
+
default: return '⚪';
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function getRiskColor(level) {
|
|
924
|
+
switch (level) {
|
|
925
|
+
case 'LOW': return theme.success;
|
|
926
|
+
case 'MEDIUM': return theme.warning;
|
|
927
|
+
case 'HIGH': return theme.error;
|
|
928
|
+
case 'CRITICAL': return theme.error;
|
|
929
|
+
default: return theme.dim;
|
|
930
|
+
}
|
|
931
|
+
}
|