@fino314-oss/contract-scanner-mcp 1.0.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 +120 -0
- package/package.json +23 -0
- package/scanner.js +447 -0
- package/server.js +270 -0
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Contract Security Scanner — MCP Server
|
|
2
|
+
|
|
3
|
+
Scan any Base L2 smart contract for security risks directly from your AI assistant.
|
|
4
|
+
|
|
5
|
+
**3 tools exposed:**
|
|
6
|
+
- `scan_contract` — Full security scan (source verification, risky selectors, age, activity)
|
|
7
|
+
- `batch_scan` — Compare up to 5 contracts side by side
|
|
8
|
+
- `interpret_risk` — Get an actionable recommendation (SAFE / CAUTION / HIGH_RISK / DO_NOT_USE)
|
|
9
|
+
|
|
10
|
+
**Risk score: 0-100.** Analyzes: mint/blacklist/backdoor functions, proxy patterns, source verification, contract age, transaction activity.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
### Claude Desktop
|
|
17
|
+
|
|
18
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"mcpServers": {
|
|
23
|
+
"contract-scanner": {
|
|
24
|
+
"command": "node",
|
|
25
|
+
"args": ["/Users/sam/Desktop/samDev/p8/mcp/server.js"]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Restart Claude Desktop. The tools appear automatically.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
### Cursor
|
|
36
|
+
|
|
37
|
+
Add to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global):
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"mcpServers": {
|
|
42
|
+
"contract-scanner": {
|
|
43
|
+
"command": "node",
|
|
44
|
+
"args": ["/Users/sam/Desktop/samDev/p8/mcp/server.js"]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
### Cline (VS Code extension)
|
|
53
|
+
|
|
54
|
+
1. Open Cline settings → MCP Servers → Add server
|
|
55
|
+
2. Set type: `stdio`
|
|
56
|
+
3. Command: `node /Users/sam/Desktop/samDev/p8/mcp/server.js`
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
### Any MCP client (generic)
|
|
61
|
+
|
|
62
|
+
The server uses **stdio transport** — just pipe JSON-RPC messages:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
node /Users/sam/Desktop/samDev/p8/mcp/server.js
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Usage examples
|
|
71
|
+
|
|
72
|
+
Once connected, just ask your AI assistant naturally:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
"Scan this contract before I approve: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
76
|
+
|
|
77
|
+
"Compare the risk of these 3 Aave clones: 0x... 0x... 0x..."
|
|
78
|
+
|
|
79
|
+
"Is this token safe to buy? 0x4ed4e862860bed51a9570b96d89af5e1b0efefed"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## What gets analyzed
|
|
85
|
+
|
|
86
|
+
| Check | Source |
|
|
87
|
+
|-------|--------|
|
|
88
|
+
| Source code verified? | BaseScan API |
|
|
89
|
+
| Mint / burn functions | Bytecode selector scan |
|
|
90
|
+
| Pause / freeze | Bytecode selector scan |
|
|
91
|
+
| Blacklist / whitelist | Bytecode selector scan |
|
|
92
|
+
| Backdoors (rescueTokens, withdrawAll) | Bytecode selector scan |
|
|
93
|
+
| Upgradeable proxy | BaseScan + delegatecall detection |
|
|
94
|
+
| Contract age | BaseScan transaction history |
|
|
95
|
+
| Activity level | BaseScan recent txs |
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Risk scoring
|
|
100
|
+
|
|
101
|
+
| Score | Label | Meaning |
|
|
102
|
+
|-------|-------|---------|
|
|
103
|
+
| 0-9 | SAFE | No red flags |
|
|
104
|
+
| 10-29 | LOW | Minor concerns |
|
|
105
|
+
| 30-49 | MEDIUM | Elevated risk — review before interacting |
|
|
106
|
+
| 50-69 | HIGH | Significant risk — small amounts only |
|
|
107
|
+
| 70+ | CRITICAL | Avoid — potential rug or backdoor |
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Technical notes
|
|
112
|
+
|
|
113
|
+
- **Chain**: Base L2 only (`https://mainnet.base.org`)
|
|
114
|
+
- **API**: BaseScan free tier (no key needed for basic checks; set `BASESCAN_API_KEY` env var for full source analysis)
|
|
115
|
+
- **No wallet needed**: read-only RPC calls only
|
|
116
|
+
- **Latency**: ~2-5s per contract (network dependent)
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
*Built on Base. Agent wallet: `0x804dd2cE4aA3296831c880139040e4326df13c6e`*
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fino314-oss/contract-scanner-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"mcpName": "io.github.fino-oss/contract-scanner",
|
|
5
|
+
"description": "MCP server that scans Base L2 smart contracts for security risks. Detects mint/blacklist/backdoor/proxy patterns. Risk score 0-100.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "server.js",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/fino-oss/contract-scanner-mcp.git"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "node server.js",
|
|
14
|
+
"inspect": "npx @modelcontextprotocol/inspector node server.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["mcp", "blockchain", "security", "base", "solidity", "smart-contracts", "defi"],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
20
|
+
"ethers": "^6.0.0",
|
|
21
|
+
"zod": "^3.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/scanner.js
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scanner.js — Contract Security Scanner
|
|
3
|
+
*
|
|
4
|
+
* Analyzes an EVM contract address and returns a risk score + findings.
|
|
5
|
+
* Uses: Base RPC (free) + Etherscan API (free tier, 100k/day)
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node research/scanner.js <contractAddress>
|
|
9
|
+
* node research/scanner.js 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { ethers } from 'ethers';
|
|
13
|
+
import { createHash } from 'crypto';
|
|
14
|
+
|
|
15
|
+
// ─── Config ──────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const BASE_RPC = 'https://mainnet.base.org';
|
|
18
|
+
const ETHERSCAN_BASE = 'https://api.etherscan.io/v2/api'; // V2 endpoint (chain-agnostic)
|
|
19
|
+
const ETHERSCAN_CHAIN = '8453'; // Base L2 chain ID
|
|
20
|
+
const ETHERSCAN_KEY = process.env.BASESCAN_API_KEY || 'YourApiKeyToken';
|
|
21
|
+
|
|
22
|
+
const provider = new ethers.JsonRpcProvider(BASE_RPC);
|
|
23
|
+
|
|
24
|
+
// ─── Known Risk Patterns in Bytecode ─────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
// Solidity function selectors for risky functions
|
|
27
|
+
const RISKY_SELECTORS = {
|
|
28
|
+
// Ownership / control
|
|
29
|
+
'f2fde38b': { name: 'transferOwnership(address)', severity: 'info', label: 'transferOwnership' },
|
|
30
|
+
'715018a6': { name: 'renounceOwnership()', severity: 'good', label: 'renounceOwnership' },
|
|
31
|
+
'e30c3978': { name: 'pendingOwner()', severity: 'info', label: 'pendingOwner' },
|
|
32
|
+
|
|
33
|
+
// Minting / supply control
|
|
34
|
+
'40c10f19': { name: 'mint(address,uint256)', severity: 'high', label: 'mint' },
|
|
35
|
+
'a0712d68': { name: 'mint(uint256)', severity: 'high', label: 'mint' },
|
|
36
|
+
'1249c58b': { name: 'mint()', severity: 'high', label: 'mint' },
|
|
37
|
+
'4e6ec247': { name: 'mintTokens(address,uint256)', severity: 'high', label: 'mint' },
|
|
38
|
+
|
|
39
|
+
// Pause / freeze
|
|
40
|
+
'8456cb59': { name: 'pause()', severity: 'medium', label: 'pause' },
|
|
41
|
+
'3f4ba83a': { name: 'unpause()', severity: 'medium', label: 'unpause' },
|
|
42
|
+
'bedb86fb': { name: 'pause(bool)', severity: 'medium', label: 'pause' },
|
|
43
|
+
|
|
44
|
+
// Blacklist / whitelist
|
|
45
|
+
'f9f92be4': { name: 'blacklist(address)', severity: 'high', label: 'blacklist' },
|
|
46
|
+
'1a895266': { name: 'blacklist(address,bool)', severity: 'high', label: 'blacklist' },
|
|
47
|
+
'42966c68': { name: 'burn(uint256)', severity: 'info', label: 'burn' },
|
|
48
|
+
'79cc6790': { name: 'burnFrom(address,uint256)', severity: 'info', label: 'burnFrom' },
|
|
49
|
+
|
|
50
|
+
// Fee manipulation
|
|
51
|
+
'518ab2a8': { name: 'setFee(uint256)', severity: 'medium', label: 'setFee' },
|
|
52
|
+
'3d3e3c6f': { name: 'setTaxFee(uint256)', severity: 'medium', label: 'setTaxFee' },
|
|
53
|
+
|
|
54
|
+
// Hidden backdoors (common in rugs)
|
|
55
|
+
'e0f7392b': { name: 'rescueTokens()', severity: 'critical', label: 'rescueTokens' },
|
|
56
|
+
'f1b9e7d8': { name: 'withdrawAll()', severity: 'critical', label: 'withdrawAll' },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ERC20 standard selectors (good signs)
|
|
60
|
+
const ERC20_SELECTORS = ['18160ddd', 'dd62ed3e', '095ea7b3', '23b872dd', 'a9059cbb', '70a08231'];
|
|
61
|
+
|
|
62
|
+
// Proxy patterns (bytecode starts with these)
|
|
63
|
+
const PROXY_PATTERNS = [
|
|
64
|
+
{ prefix: '3d602d', label: 'EIP-1167 Minimal Proxy (Clone)' },
|
|
65
|
+
{ prefix: '6080604052', label: 'Standard Contract (not a proxy)' }, // not a proxy
|
|
66
|
+
{ prefix: '363d3d373d3d3d363d73', label: 'EIP-1167 Minimal Proxy' },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// ─── Etherscan API helpers ────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
async function fetchEtherscan(params) {
|
|
72
|
+
const url = new URL(ETHERSCAN_BASE);
|
|
73
|
+
url.searchParams.set('chainid', ETHERSCAN_CHAIN);
|
|
74
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
75
|
+
url.searchParams.set('apikey', ETHERSCAN_KEY);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(url.toString());
|
|
79
|
+
const data = await res.json();
|
|
80
|
+
if (data.status === '1') return data.result;
|
|
81
|
+
return null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function getContractSource(address) {
|
|
88
|
+
return fetchEtherscan({
|
|
89
|
+
module: 'contract',
|
|
90
|
+
action: 'getsourcecode',
|
|
91
|
+
address,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function getContractABI(address) {
|
|
96
|
+
return fetchEtherscan({
|
|
97
|
+
module: 'contract',
|
|
98
|
+
action: 'getabi',
|
|
99
|
+
address,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function getTxCount(address) {
|
|
104
|
+
const result = await fetchEtherscan({
|
|
105
|
+
module: 'account',
|
|
106
|
+
action: 'txlist',
|
|
107
|
+
address,
|
|
108
|
+
startblock: 0,
|
|
109
|
+
endblock: 99999999,
|
|
110
|
+
page: 1,
|
|
111
|
+
offset: 10,
|
|
112
|
+
sort: 'desc',
|
|
113
|
+
});
|
|
114
|
+
return result ? result.length : 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Analysis functions ───────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function analyzeSelectors(bytecode) {
|
|
120
|
+
const found = [];
|
|
121
|
+
const hex = bytecode.toLowerCase().slice(2); // remove 0x
|
|
122
|
+
|
|
123
|
+
for (const [selector, info] of Object.entries(RISKY_SELECTORS)) {
|
|
124
|
+
if (hex.includes(selector)) {
|
|
125
|
+
found.push({ selector, ...info });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check ERC20 compliance
|
|
130
|
+
const erc20Count = ERC20_SELECTORS.filter(s => hex.includes(s)).length;
|
|
131
|
+
const isERC20 = erc20Count >= 4;
|
|
132
|
+
|
|
133
|
+
return { found, isERC20, erc20Count };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function detectProxy(bytecode) {
|
|
137
|
+
const hex = bytecode.toLowerCase();
|
|
138
|
+
for (const p of PROXY_PATTERNS) {
|
|
139
|
+
if (hex.includes(p.prefix) && p.label !== 'Standard Contract (not a proxy)') {
|
|
140
|
+
return { isProxy: true, type: p.label };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Check for delegatecall pattern (0xf4 opcode = delegatecall)
|
|
144
|
+
// In hex: f4 appears frequently in non-proxy contracts too, look for pattern
|
|
145
|
+
const hasDelegatecall = hex.includes('5af4');
|
|
146
|
+
return { isProxy: hasDelegatecall, type: hasDelegatecall ? 'Potential Proxy (delegatecall found)' : 'Not a proxy' };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function computeRiskScore(findings) {
|
|
150
|
+
let score = 0;
|
|
151
|
+
const weights = { critical: 30, high: 20, medium: 10, info: 3, good: -10 };
|
|
152
|
+
|
|
153
|
+
for (const f of findings) {
|
|
154
|
+
score += (weights[f.severity] || 0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return Math.max(0, Math.min(100, score));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Main scanner ─────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
export async function scanContract(address) {
|
|
163
|
+
const findings = [];
|
|
164
|
+
const meta = {};
|
|
165
|
+
|
|
166
|
+
console.log(`\n🔍 Scanning ${address} on Base...`);
|
|
167
|
+
|
|
168
|
+
// 1. Basic on-chain info
|
|
169
|
+
const [code, txCount] = await Promise.all([
|
|
170
|
+
provider.getCode(address),
|
|
171
|
+
provider.getTransactionCount(address),
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
if (code === '0x') {
|
|
175
|
+
return {
|
|
176
|
+
error: 'Not a contract',
|
|
177
|
+
address,
|
|
178
|
+
riskScore: 0,
|
|
179
|
+
findings: [],
|
|
180
|
+
summary: 'NOT_A_CONTRACT',
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
meta.bytecodeSize = (code.length - 2) / 2; // bytes
|
|
185
|
+
meta.deployerTxCount = txCount;
|
|
186
|
+
|
|
187
|
+
console.log(` Bytecode: ${meta.bytecodeSize} bytes`);
|
|
188
|
+
|
|
189
|
+
// 2. Source code verification
|
|
190
|
+
const sourceInfo = await getContractSource(address);
|
|
191
|
+
if (sourceInfo && sourceInfo[0]) {
|
|
192
|
+
const s = sourceInfo[0];
|
|
193
|
+
meta.contractName = s.ContractName || 'Unknown';
|
|
194
|
+
meta.compiler = s.CompilerVersion || '?';
|
|
195
|
+
meta.sourceVerified = s.SourceCode && s.SourceCode.length > 0;
|
|
196
|
+
meta.isProxy = s.Proxy === '1';
|
|
197
|
+
meta.implementation = s.Implementation || null;
|
|
198
|
+
|
|
199
|
+
console.log(` Name: ${meta.contractName}`);
|
|
200
|
+
console.log(` Verified: ${meta.sourceVerified}`);
|
|
201
|
+
|
|
202
|
+
if (!meta.sourceVerified) {
|
|
203
|
+
findings.push({
|
|
204
|
+
severity: 'high',
|
|
205
|
+
label: 'unverified_source',
|
|
206
|
+
name: 'Source code not verified',
|
|
207
|
+
detail: 'Cannot inspect source code — high risk of hidden backdoors',
|
|
208
|
+
});
|
|
209
|
+
} else {
|
|
210
|
+
findings.push({
|
|
211
|
+
severity: 'good',
|
|
212
|
+
label: 'verified_source',
|
|
213
|
+
name: 'Source code verified',
|
|
214
|
+
detail: `Contract: ${meta.contractName}`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (meta.isProxy) {
|
|
219
|
+
findings.push({
|
|
220
|
+
severity: 'medium',
|
|
221
|
+
label: 'upgradeable_proxy',
|
|
222
|
+
name: 'Upgradeable proxy',
|
|
223
|
+
detail: `Implementation: ${meta.implementation || 'unknown'} — owner can upgrade the logic`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
meta.sourceVerified = false;
|
|
228
|
+
findings.push({
|
|
229
|
+
severity: 'high',
|
|
230
|
+
label: 'unverified_source',
|
|
231
|
+
name: 'Source code not verified',
|
|
232
|
+
detail: 'BaseScan has no source code for this contract',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 3. Bytecode selector analysis
|
|
237
|
+
const { found: selectorFindings, isERC20 } = analyzeSelectors(code);
|
|
238
|
+
meta.isERC20 = isERC20;
|
|
239
|
+
|
|
240
|
+
if (isERC20) {
|
|
241
|
+
findings.push({
|
|
242
|
+
severity: 'good',
|
|
243
|
+
label: 'erc20_compliant',
|
|
244
|
+
name: 'ERC20 compliant',
|
|
245
|
+
detail: 'Standard transfer/approve/allowance functions detected',
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const sf of selectorFindings) {
|
|
250
|
+
findings.push({
|
|
251
|
+
severity: sf.severity,
|
|
252
|
+
label: sf.label,
|
|
253
|
+
name: sf.name,
|
|
254
|
+
detail: `Selector 0x${sf.selector} found in bytecode`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 4. Proxy bytecode detection
|
|
259
|
+
const proxyInfo = detectProxy(code);
|
|
260
|
+
if (proxyInfo.isProxy && !meta.isProxy) {
|
|
261
|
+
findings.push({
|
|
262
|
+
severity: 'medium',
|
|
263
|
+
label: 'proxy_pattern',
|
|
264
|
+
name: 'Proxy pattern detected in bytecode',
|
|
265
|
+
detail: proxyInfo.type,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 5. Age check
|
|
270
|
+
try {
|
|
271
|
+
const txs = await fetchEtherscan({
|
|
272
|
+
module: 'account',
|
|
273
|
+
action: 'txlist',
|
|
274
|
+
address,
|
|
275
|
+
startblock: 0,
|
|
276
|
+
endblock: 99999999,
|
|
277
|
+
page: 1,
|
|
278
|
+
offset: 1,
|
|
279
|
+
sort: 'asc',
|
|
280
|
+
});
|
|
281
|
+
if (txs && txs[0]) {
|
|
282
|
+
const deployTs = parseInt(txs[0].timeStamp);
|
|
283
|
+
const agedays = (Date.now() / 1000 - deployTs) / 86400;
|
|
284
|
+
meta.agedays = Math.round(agedays);
|
|
285
|
+
meta.deployer = txs[0].from;
|
|
286
|
+
|
|
287
|
+
if (agedays < 7) {
|
|
288
|
+
findings.push({
|
|
289
|
+
severity: 'high',
|
|
290
|
+
label: 'new_contract',
|
|
291
|
+
name: 'Contract deployed < 7 days ago',
|
|
292
|
+
detail: `Deployed ${meta.agedays} days ago by ${meta.deployer}`,
|
|
293
|
+
});
|
|
294
|
+
} else if (agedays < 30) {
|
|
295
|
+
findings.push({
|
|
296
|
+
severity: 'medium',
|
|
297
|
+
label: 'recent_contract',
|
|
298
|
+
name: 'Contract deployed < 30 days ago',
|
|
299
|
+
detail: `Deployed ${meta.agedays} days ago`,
|
|
300
|
+
});
|
|
301
|
+
} else {
|
|
302
|
+
findings.push({
|
|
303
|
+
severity: 'good',
|
|
304
|
+
label: 'established_contract',
|
|
305
|
+
name: `Contract established (${meta.agedays} days old)`,
|
|
306
|
+
detail: `Deployed ${meta.agedays} days ago`,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} catch { /* skip age check */ }
|
|
311
|
+
|
|
312
|
+
// 6. Transaction volume (activity check)
|
|
313
|
+
try {
|
|
314
|
+
const recentTxs = await fetchEtherscan({
|
|
315
|
+
module: 'account',
|
|
316
|
+
action: 'txlist',
|
|
317
|
+
address,
|
|
318
|
+
startblock: 0,
|
|
319
|
+
endblock: 99999999,
|
|
320
|
+
page: 1,
|
|
321
|
+
offset: 100,
|
|
322
|
+
sort: 'desc',
|
|
323
|
+
});
|
|
324
|
+
meta.recentTxCount = recentTxs ? recentTxs.length : 0;
|
|
325
|
+
|
|
326
|
+
if (meta.recentTxCount === 0) {
|
|
327
|
+
findings.push({
|
|
328
|
+
severity: 'medium',
|
|
329
|
+
label: 'no_activity',
|
|
330
|
+
name: 'No recent transactions',
|
|
331
|
+
detail: 'Contract has no transaction history — low usage or brand new',
|
|
332
|
+
});
|
|
333
|
+
} else if (meta.recentTxCount >= 10) {
|
|
334
|
+
findings.push({
|
|
335
|
+
severity: 'good',
|
|
336
|
+
label: 'active_contract',
|
|
337
|
+
name: `Active contract (${meta.recentTxCount}+ recent txs)`,
|
|
338
|
+
detail: 'Contract shows regular usage',
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
} catch { /* skip */ }
|
|
342
|
+
|
|
343
|
+
// ─── Compute risk score ────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
const riskScore = computeRiskScore(findings);
|
|
346
|
+
const riskLabel = riskScore >= 70 ? 'CRITICAL' :
|
|
347
|
+
riskScore >= 50 ? 'HIGH' :
|
|
348
|
+
riskScore >= 30 ? 'MEDIUM' :
|
|
349
|
+
riskScore >= 10 ? 'LOW' : 'SAFE';
|
|
350
|
+
|
|
351
|
+
// ─── Build result ─────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
const criticalFindings = findings.filter(f => f.severity === 'critical');
|
|
354
|
+
const highFindings = findings.filter(f => f.severity === 'high');
|
|
355
|
+
const mediumFindings = findings.filter(f => f.severity === 'medium');
|
|
356
|
+
const goodFindings = findings.filter(f => f.severity === 'good');
|
|
357
|
+
|
|
358
|
+
const summaryParts = [
|
|
359
|
+
`RISK:${riskScore}/${riskLabel}`,
|
|
360
|
+
criticalFindings.length > 0 ? `critical:${criticalFindings.map(f => f.label).join(',')}` : null,
|
|
361
|
+
highFindings.length > 0 ? `high:${highFindings.map(f => f.label).join(',')}` : null,
|
|
362
|
+
mediumFindings.length > 0 ? `medium:${mediumFindings.map(f => f.label).join(',')}` : null,
|
|
363
|
+
`age:${meta.agedays ?? '?'}d`,
|
|
364
|
+
meta.sourceVerified ? 'verified:YES' : 'verified:NO',
|
|
365
|
+
].filter(Boolean).join(' | ');
|
|
366
|
+
|
|
367
|
+
const result = {
|
|
368
|
+
address,
|
|
369
|
+
timestamp: new Date().toISOString(),
|
|
370
|
+
riskScore,
|
|
371
|
+
riskLabel,
|
|
372
|
+
meta,
|
|
373
|
+
findings,
|
|
374
|
+
summary: summaryParts.slice(0, 500), // max 500 chars for on-chain
|
|
375
|
+
criticalCount: criticalFindings.length,
|
|
376
|
+
highCount: highFindings.length,
|
|
377
|
+
mediumCount: mediumFindings.length,
|
|
378
|
+
goodCount: goodFindings.length,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// Compute result hash (keccak256 of full JSON)
|
|
382
|
+
const resultJson = JSON.stringify(result);
|
|
383
|
+
result.resultHash = '0x' + createHash('sha256').update(resultJson).digest('hex');
|
|
384
|
+
// Note: using sha256 here, contract uses bytes32 — compatible
|
|
385
|
+
|
|
386
|
+
return result;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─── Print helper ──────────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
function printResult(result) {
|
|
392
|
+
if (result.error) {
|
|
393
|
+
console.log(`\n❌ Error: ${result.error}`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const emoji = result.riskScore >= 70 ? '🔴' :
|
|
398
|
+
result.riskScore >= 50 ? '🟠' :
|
|
399
|
+
result.riskScore >= 30 ? '🟡' :
|
|
400
|
+
result.riskScore >= 10 ? '🟢' : '✅';
|
|
401
|
+
|
|
402
|
+
console.log(`\n${'═'.repeat(60)}`);
|
|
403
|
+
console.log(`${emoji} RISK SCORE: ${result.riskScore}/100 — ${result.riskLabel}`);
|
|
404
|
+
console.log(`${'═'.repeat(60)}`);
|
|
405
|
+
console.log(`Contract : ${result.address}`);
|
|
406
|
+
console.log(`Name : ${result.meta.contractName || '?'}`);
|
|
407
|
+
console.log(`Age : ${result.meta.agedays ?? '?'} days`);
|
|
408
|
+
console.log(`Verified : ${result.meta.sourceVerified ? '✅ Yes' : '❌ No'}`);
|
|
409
|
+
console.log(`Bytecode : ${result.meta.bytecodeSize} bytes`);
|
|
410
|
+
console.log(`Txs (recent): ${result.meta.recentTxCount ?? '?'}`);
|
|
411
|
+
console.log(`\nFindings:`);
|
|
412
|
+
|
|
413
|
+
const severityOrder = ['critical', 'high', 'medium', 'info', 'good'];
|
|
414
|
+
const sorted = [...result.findings].sort((a, b) =>
|
|
415
|
+
severityOrder.indexOf(a.severity) - severityOrder.indexOf(b.severity)
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
for (const f of sorted) {
|
|
419
|
+
const icon = { critical: '🔴', high: '🟠', medium: '🟡', info: 'ℹ️ ', good: '✅' }[f.severity] || '·';
|
|
420
|
+
console.log(` ${icon} [${f.severity.toUpperCase().padEnd(8)}] ${f.name}`);
|
|
421
|
+
if (f.detail) console.log(` ${f.detail}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
console.log(`\nSummary (on-chain):`);
|
|
425
|
+
console.log(` ${result.summary}`);
|
|
426
|
+
console.log(`\nResult hash: ${result.resultHash}`);
|
|
427
|
+
console.log(`${'═'.repeat(60)}\n`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ─── CLI entry point ──────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
if (process.argv[1]?.endsWith('scanner.js')) {
|
|
433
|
+
const address = process.argv[2];
|
|
434
|
+
if (!address) {
|
|
435
|
+
console.error('Usage: node research/scanner.js <contractAddress>');
|
|
436
|
+
process.exit(1);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
scanContract(address)
|
|
440
|
+
.then(result => {
|
|
441
|
+
printResult(result);
|
|
442
|
+
})
|
|
443
|
+
.catch(err => {
|
|
444
|
+
console.error('Scan failed:', err.message);
|
|
445
|
+
process.exit(1);
|
|
446
|
+
});
|
|
447
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server — Contract Security Scanner
|
|
3
|
+
*
|
|
4
|
+
* Exposes the p8 contract scanner as an MCP tool.
|
|
5
|
+
* Compatible with: Claude Desktop, Cursor, Cline, any MCP client.
|
|
6
|
+
*
|
|
7
|
+
* Usage (stdio):
|
|
8
|
+
* node /path/to/p8/mcp/server.js
|
|
9
|
+
*
|
|
10
|
+
* Tools exposed:
|
|
11
|
+
* scan_contract(address) — Full security scan of a Base contract
|
|
12
|
+
* scan_contract_quick(address) — Bytecode-only scan (no Etherscan API needed)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
16
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import { scanContract } from './scanner.js';
|
|
19
|
+
|
|
20
|
+
// ─── Server ───────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const server = new McpServer({
|
|
23
|
+
name: 'contract-scanner',
|
|
24
|
+
version: '1.0.0',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ─── Tool: scan_contract ──────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
server.tool(
|
|
30
|
+
'scan_contract',
|
|
31
|
+
`Scans an EVM smart contract on Base L2 for security risks.
|
|
32
|
+
|
|
33
|
+
Analyzes:
|
|
34
|
+
- Source code verification status
|
|
35
|
+
- Risky function selectors (mint, blacklist, pause, backdoors, etc.)
|
|
36
|
+
- Proxy / upgradeability patterns
|
|
37
|
+
- Contract age (new contracts = higher risk)
|
|
38
|
+
- Transaction activity
|
|
39
|
+
|
|
40
|
+
Returns a risk score (0-100) and categorized findings (critical/high/medium/good).
|
|
41
|
+
|
|
42
|
+
Use this before:
|
|
43
|
+
- Interacting with an unknown contract
|
|
44
|
+
- Approving a token spend
|
|
45
|
+
- Investing in a DeFi protocol
|
|
46
|
+
- Auditing a smart contract`,
|
|
47
|
+
{
|
|
48
|
+
address: z
|
|
49
|
+
.string()
|
|
50
|
+
.regex(/^0x[0-9a-fA-F]{40}$/, 'Must be a valid EVM address (0x + 40 hex chars)')
|
|
51
|
+
.describe('Contract address on Base L2 to scan'),
|
|
52
|
+
include_raw: z
|
|
53
|
+
.boolean()
|
|
54
|
+
.optional()
|
|
55
|
+
.default(false)
|
|
56
|
+
.describe('Include raw findings array in response (default: false)'),
|
|
57
|
+
},
|
|
58
|
+
async ({ address, include_raw }) => {
|
|
59
|
+
try {
|
|
60
|
+
const result = await scanContract(address);
|
|
61
|
+
|
|
62
|
+
if (result.error) {
|
|
63
|
+
return {
|
|
64
|
+
content: [{
|
|
65
|
+
type: 'text',
|
|
66
|
+
text: `❌ **Scan failed**: ${result.error}\n\nAddress \`${address}\` is not a deployed contract on Base.`,
|
|
67
|
+
}],
|
|
68
|
+
isError: true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Risk emoji
|
|
73
|
+
const emoji = result.riskScore >= 70 ? '🔴' :
|
|
74
|
+
result.riskScore >= 50 ? '🟠' :
|
|
75
|
+
result.riskScore >= 30 ? '🟡' :
|
|
76
|
+
result.riskScore >= 10 ? '🟢' : '✅';
|
|
77
|
+
|
|
78
|
+
// Build markdown response
|
|
79
|
+
const lines = [
|
|
80
|
+
`## ${emoji} Risk Score: ${result.riskScore}/100 — ${result.riskLabel}`,
|
|
81
|
+
``,
|
|
82
|
+
`**Contract**: \`${result.address}\``,
|
|
83
|
+
`**Name**: ${result.meta.contractName || 'Unknown'}`,
|
|
84
|
+
`**Age**: ${result.meta.agedays ?? '?'} days`,
|
|
85
|
+
`**Source verified**: ${result.meta.sourceVerified ? '✅ Yes' : '❌ No'}`,
|
|
86
|
+
`**Bytecode**: ${result.meta.bytecodeSize} bytes`,
|
|
87
|
+
`**Recent txs**: ${result.meta.recentTxCount ?? '?'}`,
|
|
88
|
+
``,
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// Group findings by severity
|
|
92
|
+
const bySeverity = { critical: [], high: [], medium: [], info: [], good: [] };
|
|
93
|
+
for (const f of result.findings) {
|
|
94
|
+
(bySeverity[f.severity] || bySeverity.info).push(f);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const icons = { critical: '🔴', high: '🟠', medium: '🟡', info: 'ℹ️', good: '✅' };
|
|
98
|
+
|
|
99
|
+
for (const [sev, items] of Object.entries(bySeverity)) {
|
|
100
|
+
if (items.length === 0) continue;
|
|
101
|
+
lines.push(`### ${icons[sev]} ${sev.toUpperCase()} (${items.length})`);
|
|
102
|
+
for (const f of items) {
|
|
103
|
+
lines.push(`- **${f.name}**${f.detail ? `: ${f.detail}` : ''}`);
|
|
104
|
+
}
|
|
105
|
+
lines.push('');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Summary
|
|
109
|
+
lines.push(`---`);
|
|
110
|
+
lines.push(`**On-chain summary**: \`${result.summary}\``);
|
|
111
|
+
lines.push(`**Result hash**: \`${result.resultHash}\``);
|
|
112
|
+
lines.push(`**Scanned at**: ${result.timestamp}`);
|
|
113
|
+
|
|
114
|
+
const text = lines.join('\n');
|
|
115
|
+
|
|
116
|
+
const content = [{ type: 'text', text }];
|
|
117
|
+
|
|
118
|
+
if (include_raw) {
|
|
119
|
+
content.push({
|
|
120
|
+
type: 'text',
|
|
121
|
+
text: `\n\n<details>\n<summary>Raw JSON</summary>\n\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\`\n</details>`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { content };
|
|
126
|
+
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return {
|
|
129
|
+
content: [{
|
|
130
|
+
type: 'text',
|
|
131
|
+
text: `❌ **Scanner error**: ${err.message}\n\nPlease check the address and try again.`,
|
|
132
|
+
}],
|
|
133
|
+
isError: true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// ─── Tool: batch_scan ────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
server.tool(
|
|
142
|
+
'batch_scan',
|
|
143
|
+
`Scans multiple contracts at once and returns a risk comparison table.
|
|
144
|
+
|
|
145
|
+
Useful for:
|
|
146
|
+
- Comparing multiple DeFi protocols before choosing one
|
|
147
|
+
- Auditing all contracts in a project
|
|
148
|
+
- Quick portfolio risk assessment
|
|
149
|
+
|
|
150
|
+
Scans up to 5 contracts in parallel.`,
|
|
151
|
+
{
|
|
152
|
+
addresses: z
|
|
153
|
+
.array(z.string().regex(/^0x[0-9a-fA-F]{40}$/))
|
|
154
|
+
.min(2)
|
|
155
|
+
.max(5)
|
|
156
|
+
.describe('List of 2-5 contract addresses on Base L2'),
|
|
157
|
+
},
|
|
158
|
+
async ({ addresses }) => {
|
|
159
|
+
try {
|
|
160
|
+
// Scan all in parallel
|
|
161
|
+
const results = await Promise.allSettled(
|
|
162
|
+
addresses.map(addr => scanContract(addr))
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const lines = [
|
|
166
|
+
`## 📊 Batch Scan — ${addresses.length} contracts`,
|
|
167
|
+
``,
|
|
168
|
+
`| Contract | Name | Risk | Score | Verified | Age |`,
|
|
169
|
+
`|----------|------|------|-------|----------|-----|`,
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < results.length; i++) {
|
|
173
|
+
const r = results[i];
|
|
174
|
+
if (r.status === 'rejected' || r.value?.error) {
|
|
175
|
+
lines.push(`| \`${addresses[i].slice(0, 10)}...\` | ERROR | ❓ | N/A | N/A | N/A |`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const v = r.value;
|
|
179
|
+
const emoji = v.riskScore >= 70 ? '🔴' :
|
|
180
|
+
v.riskScore >= 50 ? '🟠' :
|
|
181
|
+
v.riskScore >= 30 ? '🟡' :
|
|
182
|
+
v.riskScore >= 10 ? '🟢' : '✅';
|
|
183
|
+
|
|
184
|
+
lines.push(
|
|
185
|
+
`| \`${v.address.slice(0, 10)}...\` | ${v.meta.contractName || '?'} | ${emoji} ${v.riskLabel} | ${v.riskScore}/100 | ${v.meta.sourceVerified ? '✅' : '❌'} | ${v.meta.agedays ?? '?'}d |`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
lines.push('');
|
|
190
|
+
lines.push(`_Scanned at ${new Date().toISOString()}_`);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return {
|
|
198
|
+
content: [{ type: 'text', text: `❌ Batch scan error: ${err.message}` }],
|
|
199
|
+
isError: true,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// ─── Tool: interpret_risk ────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
server.tool(
|
|
208
|
+
'interpret_risk',
|
|
209
|
+
`Given a risk score and findings from a scan, provides an actionable recommendation.
|
|
210
|
+
|
|
211
|
+
Returns: SAFE_TO_USE / PROCEED_WITH_CAUTION / HIGH_RISK / DO_NOT_USE`,
|
|
212
|
+
{
|
|
213
|
+
risk_score: z.number().min(0).max(100).describe('Risk score from scan_contract'),
|
|
214
|
+
risk_label: z.enum(['SAFE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']).describe('Risk label from scan_contract'),
|
|
215
|
+
has_unverified_source: z.boolean().describe('Whether source code is unverified'),
|
|
216
|
+
has_backdoor: z.boolean().optional().default(false).describe('Whether rescueTokens/withdrawAll detected'),
|
|
217
|
+
context: z.enum(['token', 'defi', 'nft', 'unknown']).optional().default('unknown').describe('Type of contract being evaluated'),
|
|
218
|
+
},
|
|
219
|
+
async ({ risk_score, risk_label, has_unverified_source, has_backdoor, context }) => {
|
|
220
|
+
let verdict = '';
|
|
221
|
+
let explanation = '';
|
|
222
|
+
let actions = [];
|
|
223
|
+
|
|
224
|
+
if (has_backdoor) {
|
|
225
|
+
verdict = '🔴 DO_NOT_USE';
|
|
226
|
+
explanation = 'Backdoor functions detected (rescueTokens/withdrawAll). This contract can drain funds.';
|
|
227
|
+
actions = ['Do not approve any token spend', 'Do not deposit funds', 'Warn your community'];
|
|
228
|
+
} else if (risk_score >= 70 || (has_unverified_source && risk_score >= 40)) {
|
|
229
|
+
verdict = '🟠 HIGH_RISK';
|
|
230
|
+
explanation = `Score ${risk_score}/100 indicates significant risk. ${has_unverified_source ? 'Unverified source makes it impossible to audit fully.' : ''}`;
|
|
231
|
+
actions = ['Only interact with small amounts to test', 'Check if audited by a firm', 'Look for team doxxing'];
|
|
232
|
+
} else if (risk_score >= 30) {
|
|
233
|
+
verdict = '🟡 PROCEED_WITH_CAUTION';
|
|
234
|
+
explanation = `Score ${risk_score}/100. Some elevated-risk patterns found but no critical issues.`;
|
|
235
|
+
actions = [
|
|
236
|
+
'Check if mint/pause are behind a timelock',
|
|
237
|
+
context === 'defi' ? 'Verify TVL history before depositing' : null,
|
|
238
|
+
'Read the contract documentation',
|
|
239
|
+
].filter(Boolean);
|
|
240
|
+
} else {
|
|
241
|
+
verdict = '✅ SAFE_TO_USE';
|
|
242
|
+
explanation = `Score ${risk_score}/100. No critical issues detected. Standard risk for a ${context} contract.`;
|
|
243
|
+
actions = ['Standard due diligence still recommended', 'Monitor for contract upgrades if proxy'];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const text = [
|
|
247
|
+
`## ${verdict}`,
|
|
248
|
+
``,
|
|
249
|
+
explanation,
|
|
250
|
+
``,
|
|
251
|
+
`**Recommended actions:**`,
|
|
252
|
+
...actions.map(a => `- ${a}`),
|
|
253
|
+
].join('\n');
|
|
254
|
+
|
|
255
|
+
return { content: [{ type: 'text', text }] };
|
|
256
|
+
}
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// ─── Start ────────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
async function main() {
|
|
262
|
+
const transport = new StdioServerTransport();
|
|
263
|
+
await server.connect(transport);
|
|
264
|
+
// MCP servers communicate via stdio — no console.log here
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
main().catch(err => {
|
|
268
|
+
process.stderr.write(`MCP server error: ${err.message}\n`);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
});
|