@darksol/terminal 0.14.2 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -2
- package/package.json +1 -1
- package/src/cli.js +39 -0
- package/src/config/keys.js +9 -1
- package/src/llm/engine.js +7 -0
- package/src/llm/intent.js +17 -2
- package/src/llm/models.js +10 -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 |
|
|
@@ -491,7 +498,7 @@ darksol ai analyze AERO
|
|
|
491
498
|
darksol ai chat --provider ollama --model llama3
|
|
492
499
|
```
|
|
493
500
|
|
|
494
|
-
**Supported providers:** OpenAI, Anthropic, OpenRouter, Bankr LLM Gateway, Ollama (local = free)
|
|
501
|
+
**Supported providers:** OpenAI, Anthropic, OpenRouter, NVIDIA NIM, Bankr LLM Gateway, MiniMax, Ollama (local = free)
|
|
495
502
|
|
|
496
503
|
The AI gets live market context (prices from DexScreener), knows your config (chain, slippage, wallet), and returns structured intents with confidence scores and risk warnings.
|
|
497
504
|
|
|
@@ -517,7 +524,7 @@ darksol keys remove openai
|
|
|
517
524
|
**Supported services:**
|
|
518
525
|
| Category | Services |
|
|
519
526
|
|----------|----------|
|
|
520
|
-
| LLM | OpenAI, Anthropic, OpenRouter, Bankr LLM Gateway, Ollama |
|
|
527
|
+
| LLM | OpenAI, Anthropic, OpenRouter, NVIDIA NIM, Bankr LLM Gateway, MiniMax, Ollama |
|
|
521
528
|
| Data | CoinGecko Pro, DexScreener, DefiLlama |
|
|
522
529
|
| RPC | Alchemy, Infura, QuickNode |
|
|
523
530
|
| Trading | 1inch, ParaSwap |
|
|
@@ -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
|
@@ -27,6 +27,7 @@ import { pokerNewGame, pokerAction, pokerStatus, pokerHistory } from './services
|
|
|
27
27
|
import { cardsCatalog, cardsOrder, cardsStatus } from './services/cards.js';
|
|
28
28
|
import { facilitatorHealth, facilitatorVerify, facilitatorSettle } from './services/facilitator.js';
|
|
29
29
|
import { healthCommand } from './services/health.js';
|
|
30
|
+
import { scanToken, displayScanResult, scanResultToJSON } from './services/scanner.js';
|
|
30
31
|
import { buildersLeaderboard, buildersLookup, buildersFeed } from './services/builders.js';
|
|
31
32
|
import { createScript, listScripts, runScript, showScript, editScript, deleteScript, cloneScript, listTemplates } from './scripts/engine.js';
|
|
32
33
|
import {
|
|
@@ -775,6 +776,43 @@ export function cli(argv) {
|
|
|
775
776
|
.description('Settle payment on-chain')
|
|
776
777
|
.action((payment) => facilitatorSettle(payment));
|
|
777
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
|
+
|
|
778
816
|
// ═══════════════════════════════════════
|
|
779
817
|
// APPROVALS COMMANDS
|
|
780
818
|
// ═══════════════════════════════════════
|
|
@@ -2061,6 +2099,7 @@ function showCommandList() {
|
|
|
2061
2099
|
['price', 'Quick token price check'],
|
|
2062
2100
|
['watch', 'Live price monitoring + alerts'],
|
|
2063
2101
|
['gas', 'Gas prices & cost estimates'],
|
|
2102
|
+
['scan', 'Token security scanner'],
|
|
2064
2103
|
['trade', 'Swap tokens, snipe, trading'],
|
|
2065
2104
|
['arb', 'Cross-DEX arbitrage scanner'],
|
|
2066
2105
|
['auto', 'Autonomous trader strategies'],
|
package/src/config/keys.js
CHANGED
|
@@ -92,6 +92,14 @@ export const SERVICES = {
|
|
|
92
92
|
docsUrl: 'https://platform.minimax.io/docs/guides/models-intro',
|
|
93
93
|
validate: (key) => key.length > 10,
|
|
94
94
|
},
|
|
95
|
+
nvidia: {
|
|
96
|
+
name: 'NVIDIA NIM',
|
|
97
|
+
category: 'llm',
|
|
98
|
+
description: 'NVIDIA NIM — Llama, Nemotron, Mistral via build.nvidia.com',
|
|
99
|
+
envVar: 'NVIDIA_API_KEY',
|
|
100
|
+
docsUrl: 'https://build.nvidia.com',
|
|
101
|
+
validate: (key) => key.startsWith('nvapi-') || key.length > 10,
|
|
102
|
+
},
|
|
95
103
|
ollama: {
|
|
96
104
|
name: 'Ollama (Local)',
|
|
97
105
|
category: 'llm',
|
|
@@ -454,7 +462,7 @@ export function hasKey(service) {
|
|
|
454
462
|
*/
|
|
455
463
|
export function hasAnyLLM() {
|
|
456
464
|
// Cloud providers — need real validated API keys
|
|
457
|
-
if (['openai', 'anthropic', 'openrouter', 'minimax', 'bankr'].some(s => hasKey(s))) return true;
|
|
465
|
+
if (['openai', 'anthropic', 'openrouter', 'minimax', 'nvidia', 'bankr'].some(s => hasKey(s))) return true;
|
|
458
466
|
// Ollama — check if explicitly configured via hasKey (validates URL format)
|
|
459
467
|
if (hasKey('ollama')) return true;
|
|
460
468
|
return false;
|
package/src/llm/engine.js
CHANGED
|
@@ -47,6 +47,13 @@ const PROVIDERS = {
|
|
|
47
47
|
parseResponse: (data) => data.choices?.[0]?.message?.content,
|
|
48
48
|
parseUsage: (data) => data.usage,
|
|
49
49
|
},
|
|
50
|
+
nvidia: {
|
|
51
|
+
url: 'https://integrate.api.nvidia.com/v1/chat/completions',
|
|
52
|
+
defaultModel: getProviderDefaultModel('nvidia'),
|
|
53
|
+
authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
|
|
54
|
+
parseResponse: (data) => data.choices?.[0]?.message?.content,
|
|
55
|
+
parseUsage: (data) => data.usage,
|
|
56
|
+
},
|
|
50
57
|
ollama: {
|
|
51
58
|
url: null,
|
|
52
59
|
defaultModel: getProviderDefaultModel('ollama'),
|
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';
|
package/src/llm/models.js
CHANGED
|
@@ -39,6 +39,16 @@ export const MODEL_CATALOG = {
|
|
|
39
39
|
{ value: 'MiniMax-M2', label: 'MiniMax-M2', desc: 'agentic, advanced reasoning' },
|
|
40
40
|
],
|
|
41
41
|
},
|
|
42
|
+
nvidia: {
|
|
43
|
+
defaultModel: 'nvidia/llama-3.1-nemotron-70b-instruct',
|
|
44
|
+
choices: [
|
|
45
|
+
{ value: 'nvidia/llama-3.1-nemotron-70b-instruct', label: 'Nemotron 70B', desc: 'high reasoning + tool calling' },
|
|
46
|
+
{ value: 'meta/llama-3.1-8b-instruct', label: 'Llama 3.1 8B', desc: 'fast, free tier friendly' },
|
|
47
|
+
{ value: 'meta/llama-3.1-70b-instruct', label: 'Llama 3.1 70B', desc: 'strong general purpose' },
|
|
48
|
+
{ value: 'mistralai/mistral-large-2-instruct', label: 'Mistral Large 2', desc: 'multilingual, coding' },
|
|
49
|
+
{ value: 'nvidia/nemotron-mini-4b-instruct', label: 'Nemotron Mini 4B', desc: 'ultra-fast, lightweight' },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
42
52
|
ollama: {
|
|
43
53
|
defaultModel: 'llama3.1',
|
|
44
54
|
textInput: true,
|
|
@@ -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
|
+
}
|