An ERC-20 rug-pull risk analyzer for Ethereum mainnet. Exposes the same analysis through three interfaces:
| Interface | Audience | Entry point |
|---|---|---|
| MCP server | AI agents (Claude Desktop, any MCP client) | node src/mcp-server.js |
| HTTP service | Web dashboards, scripts, integrations | node src/server.js |
| CLI | Humans at a terminal | node cli.js --token 0x... |
The HTTP service is gated by real x402 payments β USDC on Base Sepolia, settled by the Coinbase-hosted facilitator at x402.org/facilitator. Clients hit the endpoint, get 402 Payment Required with the payment requirements, sign an EIP-3009 transfer authorization with a viem wallet, replay the request with an X-Payment header, and the facilitator verifies + settles the transfer before the analyzer runs.
For any ERC-20 address, the analyzer pulls four independent signals and combines them into a single 0β100 risk score.
| Signal | What it measures | Data source |
|---|---|---|
| Holder distribution | Total holders, top-1 share, top-10 share, approx Gini over top-10 | Ethplorer free API (getTokenInfo, getTopTokenHolders) |
| Contract verification | Source code published on Etherscan? Contract name + compiler version. Proxy-aware: detects EIP-1967, EIP-1967 beacon, and legacy zeppelinOS proxies, then resolves the implementation and reports its verification status. | Etherscan API V2 + eth_getStorageAt on well-known proxy slots |
| Dangerous functions | Pattern-matches verified source for selfdestruct, pause modifiers, emergency-withdraw, owner-gated mint, blacklist, fee/tax setters. Comments stripped before matching. For proxies, the implementation source is scanned (not the delegatecall stub). Response includes confidence: "pattern-scan-only" so callers know not to treat this as a formal audit. |
Etherscan source code (proxy + implementation) |
| Liquidity | Total Uniswap V3 TVL across (WETH/USDC/USDT/DAI) Γ (0.01% / 0.05% / 0.30% / 1.00%) fee tiers, plus per-pool breakdown. Real on-chain: calls UniswapV3Factory.getPool() for every combination via Etherscan's eth_call proxy, then balanceOf(pool) on the pair token to compute USD-denominated reserves. Works for any ERC-20, no hardcoded fallback. |
Etherscan eth_call to UniV3 Factory + pair-token balanceOf |
The score is bounded [0, 100]. Bands:
0β19βVERY_LOWβ20β39βLOWβ40β59βMODERATEβ οΈ 60β79βHIGHβ οΈ 80β100βCRITICALβ
token-aggregator/
βββ src/
β βββ mcp-server.js MCP server over stdio (@modelcontextprotocol/sdk)
β βββ server.js Express + x402-express; serves /tools/* and /demo/analyze
β βββ tools/
β βββ analyzeTokenSecurity.js Orchestrator β combines the four signals
β βββ holderAnalysis.js Ethplorer-backed real holder data
β βββ contractVerified.js Proxy-aware Etherscan source-code lookup
β βββ dangerousFunctions.js Pattern scan over proxy + implementation source
β βββ liquidityAnalysis.js On-chain Uniswap V3 TVL via Etherscan eth_call
β βββ proxyResolver.js EIP-1967 / beacon / zeppelinOS slot probing
β βββ etherscanRpc.js Throttled Etherscan v2 client (eth_call, etc.)
βββ frontend/ React + Vite dashboard
βββ cli.js Terminal entry point
βββ .env.example Required + optional env vars
npm install
cd frontend && npm install && cd ..Copy .env.example to .env and fill in your Etherscan API key:
cp .env.example .env
# edit .env, set ETHERSCAN_KEY=...Get a free key at https://etherscan.io/apis. Ethplorer and DeFiLlama don't require a key.
# CLI
node cli.js --token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
# HTTP server (port 3000)
node src/server.js
# In another terminal, also start the dashboard
cd frontend && npm run dev # http://localhost:5173
# MCP server (stdin/stdout β driven by an MCP client)
node src/mcp-server.jsEdit ~/Library/Application Support/Claude/claude_desktop_config.json (or the equivalent on your OS):
{
"mcpServers": {
"tokenSecurityAnalyzer": {
"command": "node",
"args": ["/absolute/path/to/token-aggregator/src/mcp-server.js"],
"env": {
"ETHERSCAN_KEY": "your_etherscan_api_key"
}
}
}
}Fully quit Claude Desktop (βQ) and relaunch. The π tools icon in a new chat will list analyze_token_security. Then ask Claude something like:
Analyze the security of token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.
The HTTP route /tools/analyze_token_security is wrapped in x402-express middleware. There is no separate /api/pay endpoint β the protocol handles payment in the same request:
GET /tools/analyze_token_security?token_address=0x...
β 402 Payment Required
body: { x402Version, accepts: [{ scheme:"exact", network:"base-sepolia",
maxAmountRequired, asset (USDC), payTo, β¦ }] }
(client signs an EIP-3009 transferWithAuthorization for that exact amount/recipient using viem)
GET /tools/analyze_token_security?token_address=0x...
X-Payment: <base64 signed payload>
β middleware POSTs the payload to the facilitator β /verify β /settle (USDC moves on-chain)
β 200 { success: true, data: { riskScore, riskLevel, analysis, allFlags, β¦ } }
X-Payment-Response: <base64 settlement receipt incl. tx hash>
Three ways to drive that flow:
| Caller | How |
|---|---|
| CLI (real x402) | PRIVATE_KEY=0x... node cli.js --token 0x... --paid β uses x402-fetch with a viem signer. Prints the settlement receipt (tx hash on Base Sepolia). |
| Dashboard | Hits /demo/analyze, a server-side proxy that internally calls /tools/... with x402-fetch and the server's own DEMO_PRIVATE_KEY. Real x402 happens server-side so the browser doesn't need MetaMask. |
| Direct curl | curl http://localhost:3000/tools/analyze_token_security?token_address=0x... returns the raw 402 body for inspection. |
Funding a test wallet: generate a private key, send it to https://faucet.circle.com/ (select Base Sepolia β USDC) plus a small drip of Base Sepolia ETH for the EIP-3009 sender from https://www.alchemy.com/faucets/base-sepolia. The facilitator settles for you; users don't need ETH on every call once the wallet is funded.
The facilitator URL, network, price, and payTo wallet are all .env knobs (X402_FACILITATOR_URL, X402_NETWORK, X402_PRICE, PAYMENT_ADDRESS). Flip X402_NETWORK=base for mainnet β everything else stays the same.
These came up during the build and are worth knowing about if you're reading the code:
An earlier prototype tried to read JSON-RPC off stdin with a custom message format. Real MCP clients (Claude Desktop, etc.) speak strict JSON-RPC 2.0 with an initialize handshake and notifications/initialized flow, so the original wouldn't connect. We now use @modelcontextprotocol/sdk's McpServer + StdioServerTransport with a Zod-validated tool schema, which handles all of that.
analyzeTokenSecurity and friends log with console.error, never console.log. When the process is launched as an MCP server, stdout carries JSON-RPC framing β a stray console.log would corrupt the stream and Claude Desktop would silently drop the server.
The free Etherscan tier is documented as 5 req/sec but enforced more aggressively than that. A single analysis now fires upward of 20 Etherscan calls (proxy slot reads, source-code fetches for proxy + implementation, factory getPool per pair Γ fee tier, balanceOf per pool, ETH price). All of them funnel through one throttle in src/tools/etherscanRpc.js that enforces a 350 ms gap between consecutive requests, plus a one-shot retry with a 1.5 s back-off on NOTOK responses. Earlier versions ran calls in Promise.all and falsely reported verified=false for properly-verified contracts (e.g. LINK scoring 80/100 CRITICAL). Holder analysis (Ethplorer) is the only thing that runs in parallel with the Etherscan work, since it's a different upstream.
An earlier version generated holder counts and concentration percentages with Math.random() for any token not in a 3-token hardcoded table. Same address, different answers on every call. Holder data is now fetched from Ethplorer's free public endpoint, which returns the actual on-chain holder count and top-10 list. USDC reports 6,874,784 holders (the real number), SHIB's top holder is the 0xdeadβ¦ burn address at 41.04 % β the analyzer doesn't try to detect burn addresses; it conservatively flags this as concentration risk.
The original patterns like 'paused' and 'blacklist' would match those words anywhere in source code β in comments, doc strings, variable names. The current patterns:
- Strip
//β¦and/* β¦ */comments before scanning - Match function-call / declaration shape:
\bselfdestruct\s*\(,\b(function|modifier)\s+pause\s*\( - Stop at the first match per category (so one risk doesn't get counted twice)
This won't catch every adversarial obfuscation, but it cuts down on the false-positive rate on legitimate contracts.
Each tool checks /^0x[a-fA-F0-9]{40}$/ before assembling the URL, and uses encodeURIComponent on values inserted into Etherscan URLs. The HTTP endpoint and MCP schema both reject anything else with a clear error.
fetch(url, { timeout: 5000 }) is silently ignored by Node's native fetch β it only accepts signal. All external calls use a small fetchWithTimeout helper that wires up an AbortController and clears the timer in finally.
Most modern ERC-20s are proxy contracts β the address you trade against is a thin delegatecall stub, and the real code lives in an implementation contract that the proxy can be upgraded to point at. The verification check and the dangerous-function scanner both go through proxyResolver.js, which reads storage at three well-known slots in order:
- EIP-1967 implementation slot (
keccak256("eip1967.proxy.implementation") - 1) β the modern OpenZeppelin standard. - EIP-1967 beacon slot β for beacon proxies; if present, we hop through the beacon contract's
implementation()getter. - Legacy zeppelinOS slot (
keccak256("org.zeppelinos.proxy.implementation")) β used by Circle's FiatTokenProxy (USDC) and other 2018-era OpenZeppelin upgrade chains.
If any slot resolves, both contractVerified and dangerousFunctions operate on the concatenation of the proxy source and the implementation source. Without this, scanning USDC would look at a 50-line delegatecall stub, miss the Pausable and Blacklistable modifiers on the actual implementation, and produce a falsely clean result. The verification response surfaces isProxy, proxyKind, and the implementation address + name so callers can see what was actually scanned.
The previous version hit DeFiLlama's API and fell back to a hardcoded table of six tokens when DeFiLlama was unreachable. Anything outside {USDC, WETH, USDT, DAI, LINK, SHIB} falsely reported $0 liquidity and got a 40-point risk hit. The current implementation calls UniswapV3Factory.getPool(token, pairToken, fee) for every combination of {WETH, USDC, USDT, DAI} Γ {0.01%, 0.05%, 0.30%, 1.00%} via Etherscan's eth_call proxy, then reads the pair-token reserve in each found pool with balanceOf(pool). USD is computed directly for stablecoin pairs and via Etherscan's live ETH/USD price for WETH pairs. Pool TVL is approximated as 2 Γ pair-side reserve. Works for any ERC-20. The factory address gotcha that bit me: the official UniV3 mainnet factory is 0x1F98431c8aD98523631AE4a59f267346ea31F984 (ends in 1F984, not 113FA as some sources have it).
A regex-based scan over Solidity source is a useful pre-screen, but it can't reason about reachability, access control, hidden delegatecalls, or anything in assembly. The response now carries an explicit confidence: "pattern-scan-only" field and a disclaimer string explaining the limits, so a calling agent (which can't read source code itself) treats the result as "investigate further" rather than "this is safe / unsafe". The "no patterns detected" flag also reminds you it's not a formal audit. The premise: a service for agents should over-communicate its own confidence so the agent can make a calibrated decision.
Beyond the MCP rewrite and the accuracy fixes, the build addressed a handful of vulnerabilities found while reviewing the original code:
| Issue | Fix |
|---|---|
Mock /api/pay with guessable IDs and an unbounded in-memory map |
Replaced wholesale with real x402 (x402-express) settling USDC via the Coinbase facilitator |
token_address not validated in HTTP route |
Regex check + encodeURIComponent |
.env (with real API key) committed despite .gitignore |
Untracked. Key in commit 46b67e6 of the public repo still needs rotation. |
.env had DEMO_MODE=trueETHERSCAN_KEY=... on one concatenated line |
Cleaned and replaced with a proper .env.example |
- Etherscan free tier (100 k req/day, 5 req/s, enforced jittery). A single analysis costs ~20 calls and takes ~12-15 s with the 350 ms throttle. Concurrent analyses serialize through the same queue. Upgrade to a paid Etherscan plan, or run multiple keys, for production throughput.
- Ethplorer's
freekeyis rate-limited. For production, register for a free Ethplorer key and setETHPLORER_KEYin.env. - Testnet by default. The default config settles real USDC on Base Sepolia (free, faucet-funded). Flip
X402_NETWORK=baseand fund the wallet with real USDC to switch to mainnet β no other code change. - Burn-address concentration looks like rug risk. SHIB's top holder is
0xdeadβ¦holding 41 % β the analyzer flags it as concentration. Detecting known burn addresses is a follow-up. - Pattern scanner won't catch obfuscated dangerous functions (delegate calls into a hidden implementation, function names shortened in assembly, etc.). It's a pre-screen, not a formal audit β the response says so explicitly via
confidence: "pattern-scan-only". - Liquidity covers Uniswap V3 only. Tokens whose liquidity lives on V2, Sushiswap, Curve, or other L1/L2 chains will look thinner than they really are. SHIB on V3 alone is ~$340k, while its real cross-DEX depth is much larger.
- Proxy resolution covers 3 proxy patterns (EIP-1967, EIP-1967 beacon, legacy zeppelinOS). Non-standard proxies (Gnosis Safe-style slot-0 implementations, custom diamond cuts, etc.) will be treated as non-proxy contracts and only the stub will be scanned.
$ node cli.js --token 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
π RISK SCORE: 0/100 [VERY_LOW]
β
VERY LOW RISK - Excellent security profile.
π RISK BREAKDOWN:
β’ Holder Concentration: 0
β’ Contract Verification: -15
β’ Dangerous Functions: 0
β’ Liquidity: 0
π₯ HOLDER DISTRIBUTION:
β’ Total Holders: 6,874,784
β’ Top Holder: 21.50%
β’ Top 10: 27.87%
β’ Gini Coefficient: 0.612
β
CONTRACT VERIFICATION:
β
Contract verified - Code is public (FiatTokenProxy)
β’ Name: FiatTokenProxy
β’ Compiler: v0.4.24+commit.e67f0147
MIT.