chainhint-mcp 1.0.0 → 1.1.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 +37 -16
- package/dist/index.d.ts +6 -4
- package/dist/index.js +254 -90
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,16 +4,16 @@ Crypto risk intelligence for Claude Desktop, Cursor, and any MCP-compatible AI.
|
|
|
4
4
|
|
|
5
5
|
## Tools
|
|
6
6
|
|
|
7
|
-
| Tool | Description |
|
|
8
|
-
|
|
9
|
-
| `check_wallet_risk` |
|
|
10
|
-
| `lookup_address` |
|
|
11
|
-
| `get_trace_status` | Fund
|
|
7
|
+
| Tool | Description | Limit |
|
|
8
|
+
|------|-------------|-------|
|
|
9
|
+
| `check_wallet_risk` | Fast risk score, entity, labels, sanctions hit (54M+ labeled addresses) | **3 / day** free · 10,000 / day with key |
|
|
10
|
+
| `lookup_address` | Deep address report — entity, risk factors, GoPlus flags, counterparty exposure, balance | **10 / day** free · 100 / hour with key |
|
|
11
|
+
| `get_trace_status` | Fund-trace summary for a public hack incident — hops, endpoints by type (exchange/mixer/bridge/defi), exposure | Unlimited |
|
|
12
12
|
|
|
13
13
|
## Requirements
|
|
14
14
|
|
|
15
15
|
- Node.js 18+
|
|
16
|
-
-
|
|
16
|
+
- Nothing else. **No API key needed to start** — every tool has a free daily allowance (per IP). An Agency plan key (`ch_live_...`, [chainhint.com/settings](https://chainhint.com/settings)) lifts the limits.
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
@@ -38,10 +38,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
|
38
38
|
"mcpServers": {
|
|
39
39
|
"chainhint": {
|
|
40
40
|
"command": "npx",
|
|
41
|
-
"args": ["-y", "chainhint-mcp"]
|
|
42
|
-
"env": {
|
|
43
|
-
"CHAINHINT_API_KEY": "ch_live_your_key_here"
|
|
44
|
-
}
|
|
41
|
+
"args": ["-y", "chainhint-mcp"]
|
|
45
42
|
}
|
|
46
43
|
}
|
|
47
44
|
}
|
|
@@ -56,15 +53,39 @@ Add to `.cursor/mcp.json` in your project (or global `~/.cursor/mcp.json`):
|
|
|
56
53
|
"mcpServers": {
|
|
57
54
|
"chainhint": {
|
|
58
55
|
"command": "npx",
|
|
59
|
-
"args": ["-y", "chainhint-mcp"]
|
|
60
|
-
"env": {
|
|
61
|
-
"CHAINHINT_API_KEY": "ch_live_your_key_here"
|
|
62
|
-
}
|
|
56
|
+
"args": ["-y", "chainhint-mcp"]
|
|
63
57
|
}
|
|
64
58
|
}
|
|
65
59
|
}
|
|
66
60
|
```
|
|
67
61
|
|
|
62
|
+
## Claude Code
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
claude mcp add chainhint -- npx -y chainhint-mcp
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Lifting the free limits
|
|
69
|
+
|
|
70
|
+
Add an Agency key to the server entry (any client):
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
"env": { "CHAINHINT_API_KEY": "ch_live_your_key_here" }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## What the agent sees
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
## Wallet Risk Report: 0x47666f...9486e2
|
|
80
|
+
**Chain:** ethereum
|
|
81
|
+
**Risk Score:** 85/100 — **CRITICAL**
|
|
82
|
+
**Entity:** Bybit Hack Exploiter (hacker)
|
|
83
|
+
**Labels:** Hacker/Exploiter, Bybit Hack Exploiter
|
|
84
|
+
**In ChainHint DB:** yes (sources: chainhint:manual)
|
|
85
|
+
|
|
86
|
+
Free tier: 2 of 3 checks left today — set CHAINHINT_API_KEY (Agency plan, https://chainhint.com/pricing) for 10,000/day.
|
|
87
|
+
```
|
|
88
|
+
|
|
68
89
|
## Development (no build step)
|
|
69
90
|
|
|
70
91
|
```bash
|
|
@@ -75,9 +96,9 @@ npm run dev
|
|
|
75
96
|
|
|
76
97
|
| Variable | Required | Description |
|
|
77
98
|
|----------|----------|-------------|
|
|
78
|
-
| `CHAINHINT_API_KEY` |
|
|
99
|
+
| `CHAINHINT_API_KEY` | — | Agency plan key from chainhint.com. Lifts the free limits (3 checks + 10 lookups per day) to 10,000 / day |
|
|
79
100
|
| `CHAINHINT_API_URL` | — | Override API base URL (default: production) |
|
|
80
|
-
| `CHAINHINT_SUPABASE_ANON_KEY` | — |
|
|
101
|
+
| `CHAINHINT_SUPABASE_ANON_KEY` | — | Override anon key for get_trace_status (public incidents) |
|
|
81
102
|
|
|
82
103
|
## Example prompts
|
|
83
104
|
|
package/dist/index.d.ts
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* and any MCP-compatible AI client.
|
|
7
7
|
*
|
|
8
8
|
* Tools:
|
|
9
|
-
* - check_wallet_risk → wallet-reputation API (risk score, labels, sanctions)
|
|
10
|
-
* - lookup_address → address-lookup API (entity,
|
|
11
|
-
* - get_trace_status → incident fund
|
|
9
|
+
* - check_wallet_risk → wallet-reputation API (risk score, labels, sanctions) — needs API key
|
|
10
|
+
* - lookup_address → address-lookup API (entity, risk factors, exposure, balance) — public
|
|
11
|
+
* - get_trace_status → public incident fund-trace summary (endpoints, hops, exposure) — public
|
|
12
12
|
*
|
|
13
|
-
* Auth:
|
|
13
|
+
* Auth: works without a key. check_wallet_risk has a free tier of 3 checks
|
|
14
|
+
* per IP per day; lookup_address 10 per day; get_trace_status is unlimited.
|
|
15
|
+
* CHAINHINT_API_KEY (Agency plan, ch_live_...) lifts the limits to 10,000/day.
|
|
14
16
|
*/
|
|
15
17
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
* and any MCP-compatible AI client.
|
|
7
7
|
*
|
|
8
8
|
* Tools:
|
|
9
|
-
* - check_wallet_risk → wallet-reputation API (risk score, labels, sanctions)
|
|
10
|
-
* - lookup_address → address-lookup API (entity,
|
|
11
|
-
* - get_trace_status → incident fund
|
|
9
|
+
* - check_wallet_risk → wallet-reputation API (risk score, labels, sanctions) — needs API key
|
|
10
|
+
* - lookup_address → address-lookup API (entity, risk factors, exposure, balance) — public
|
|
11
|
+
* - get_trace_status → public incident fund-trace summary (endpoints, hops, exposure) — public
|
|
12
12
|
*
|
|
13
|
-
* Auth:
|
|
13
|
+
* Auth: works without a key. check_wallet_risk has a free tier of 3 checks
|
|
14
|
+
* per IP per day; lookup_address 10 per day; get_trace_status is unlimited.
|
|
15
|
+
* CHAINHINT_API_KEY (Agency plan, ch_live_...) lifts the limits to 10,000/day.
|
|
14
16
|
*/
|
|
15
17
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
18
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -20,30 +22,55 @@ const API_KEY = process.env.CHAINHINT_API_KEY;
|
|
|
20
22
|
const BASE_URL = process.env.CHAINHINT_API_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co/functions/v1";
|
|
21
23
|
const SUPABASE_URL = process.env.CHAINHINT_SUPABASE_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co";
|
|
22
24
|
const SUPABASE_ANON_KEY = process.env.CHAINHINT_SUPABASE_ANON_KEY ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtqaXdmd3ltbnV6eHJpb2toY2prIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI3MzkxODgsImV4cCI6MjA4ODMxNTE4OH0.VqzzF_jI8zF072cbjWEDbYo3PnMDlIPy621iWkXEqyo";
|
|
25
|
+
const VERSION = "1.1.0";
|
|
26
|
+
const USER_AGENT = `chainhint-mcp/${VERSION}`;
|
|
27
|
+
const FREE_CHECKS_PER_DAY = 3;
|
|
28
|
+
const UPGRADE_HINT = "set CHAINHINT_API_KEY (Agency plan, https://chainhint.com/pricing) for 10,000/day";
|
|
23
29
|
if (!API_KEY) {
|
|
24
|
-
console.error(
|
|
25
|
-
console.error(" Get your API key from chainhint.com → Settings → API Keys (Agency plan required)");
|
|
26
|
-
process.exit(1);
|
|
30
|
+
console.error(`[chainhint-mcp] No CHAINHINT_API_KEY — running on the free tier (${FREE_CHECKS_PER_DAY} wallet checks + 10 lookups per day per IP).`);
|
|
27
31
|
}
|
|
28
|
-
|
|
29
|
-
async function apiGet(path, params = {}) {
|
|
32
|
+
async function apiGet(path, params) {
|
|
30
33
|
const url = new URL(`${BASE_URL}${path}`);
|
|
31
34
|
for (const [k, v] of Object.entries(params)) {
|
|
32
35
|
if (v)
|
|
33
36
|
url.searchParams.set(k, v);
|
|
34
37
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
38
|
+
// User-Agent lets the backend attribute traffic (api_usage_log.caller) and
|
|
39
|
+
// apply the MCP-specific free allowance. The key, when present, goes in
|
|
40
|
+
// X-Api-Key so both wallet-reputation and address-lookup recognise it.
|
|
41
|
+
const headers = {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
"User-Agent": USER_AGENT,
|
|
44
|
+
};
|
|
45
|
+
if (API_KEY) {
|
|
46
|
+
headers["X-Api-Key"] = API_KEY;
|
|
47
|
+
headers["Authorization"] = `Bearer ${API_KEY}`;
|
|
48
|
+
}
|
|
49
|
+
const res = await fetch(url.toString(), { headers });
|
|
50
|
+
const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` })));
|
|
43
51
|
if (!res.ok) {
|
|
44
|
-
|
|
52
|
+
let msg = body?.error ?? `HTTP ${res.status}: ${url.toString()}`;
|
|
53
|
+
if (res.status === 429 && body?.reset_at) {
|
|
54
|
+
msg += ` Resets at ${new Date(body.reset_at * 1000).toISOString()}.`;
|
|
55
|
+
}
|
|
56
|
+
throw new Error(msg);
|
|
45
57
|
}
|
|
46
|
-
return body;
|
|
58
|
+
return { body, headers: res.headers };
|
|
59
|
+
}
|
|
60
|
+
/** "Free tier: 2 of 3 checks left today — set CHAINHINT_API_KEY …" or null. */
|
|
61
|
+
function quotaLine(headers, what) {
|
|
62
|
+
if (headers.get("x-chainhint-tier") !== "free")
|
|
63
|
+
return null;
|
|
64
|
+
const limit = Number(headers.get("x-ratelimit-limit"));
|
|
65
|
+
const remaining = Number(headers.get("x-ratelimit-remaining"));
|
|
66
|
+
if (!Number.isFinite(limit) || !Number.isFinite(remaining))
|
|
67
|
+
return null;
|
|
68
|
+
// Bypass-listed IPs get MAX_SAFE_INTEGER from the backend — no quota to report.
|
|
69
|
+
if (limit >= 1_000_000)
|
|
70
|
+
return null;
|
|
71
|
+
const reset = Number(headers.get("x-ratelimit-reset"));
|
|
72
|
+
const resetStr = Number.isFinite(reset) && reset > 0 ? ` (resets ${new Date(reset * 1000).toISOString().slice(0, 16)}Z)` : "";
|
|
73
|
+
return `Free tier: ${remaining} of ${limit} ${what} left today${resetStr} — ${UPGRADE_HINT}.`;
|
|
47
74
|
}
|
|
48
75
|
async function supabaseGet(table, params) {
|
|
49
76
|
const url = new URL(`${SUPABASE_URL}/rest/v1/${table}`);
|
|
@@ -54,7 +81,6 @@ async function supabaseGet(table, params) {
|
|
|
54
81
|
headers: {
|
|
55
82
|
"apikey": SUPABASE_ANON_KEY,
|
|
56
83
|
"Authorization": `Bearer ${SUPABASE_ANON_KEY}`,
|
|
57
|
-
"X-Api-Key": API_KEY,
|
|
58
84
|
"Accept": "application/json",
|
|
59
85
|
},
|
|
60
86
|
});
|
|
@@ -79,13 +105,54 @@ function formatRiskLevel(score) {
|
|
|
79
105
|
function truncateAddr(addr) {
|
|
80
106
|
return addr.length > 12 ? `${addr.slice(0, 8)}...${addr.slice(-6)}` : addr;
|
|
81
107
|
}
|
|
108
|
+
function usd(n) {
|
|
109
|
+
if (n == null || !Number.isFinite(n))
|
|
110
|
+
return "Unknown";
|
|
111
|
+
if (Math.abs(n) >= 1_000_000)
|
|
112
|
+
return `$${(n / 1_000_000).toFixed(2)}M`;
|
|
113
|
+
if (Math.abs(n) >= 1_000)
|
|
114
|
+
return `$${(n / 1_000).toFixed(1)}K`;
|
|
115
|
+
return `$${n.toFixed(2)}`;
|
|
116
|
+
}
|
|
117
|
+
// EVM addresses are case-insensitive and stored lowercase; base58 chains
|
|
118
|
+
// (BTC/TRON/SOL/TON) are case-sensitive — never lowercase those.
|
|
119
|
+
function normalizeAddr(addr) {
|
|
120
|
+
const a = addr.trim();
|
|
121
|
+
return a.startsWith("0x") ? a.toLowerCase() : a;
|
|
122
|
+
}
|
|
123
|
+
function formatExposure(label, buckets) {
|
|
124
|
+
if (!buckets?.length)
|
|
125
|
+
return [];
|
|
126
|
+
const sorted = [...buckets].sort((a, b) => (b.usd ?? 0) - (a.usd ?? 0)).slice(0, 5);
|
|
127
|
+
return [
|
|
128
|
+
`**${label}:**`,
|
|
129
|
+
...sorted.map((b) => {
|
|
130
|
+
const who = b.top_entities?.length ? ` — ${b.top_entities.slice(0, 3).join(", ")}` : "";
|
|
131
|
+
return `- ${b.category}: ${usd(b.usd)} (${b.pct}%, ${b.counterparties} counterpart${b.counterparties === 1 ? "y" : "ies"})${who}`;
|
|
132
|
+
}),
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
/** Public incident whose attacker_address matches, or null (errors swallowed — best effort). */
|
|
136
|
+
async function findPublicIncidentByAttacker(address) {
|
|
137
|
+
try {
|
|
138
|
+
const rows = await supabaseGet("public_incidents_view", {
|
|
139
|
+
select: "id,title,chain,amount_usd,estimated_loss_usd,risk_score",
|
|
140
|
+
attacker_address: `eq.${normalizeAddr(address)}`,
|
|
141
|
+
limit: "1",
|
|
142
|
+
});
|
|
143
|
+
return rows[0] ?? null;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
82
149
|
// ── MCP Server ────────────────────────────────────────────────────────────────
|
|
83
150
|
const server = new McpServer({
|
|
84
151
|
name: "chainhint",
|
|
85
|
-
version:
|
|
152
|
+
version: VERSION,
|
|
86
153
|
});
|
|
87
154
|
// ── Tool 1: check_wallet_risk ─────────────────────────────────────────────────
|
|
88
|
-
server.tool("check_wallet_risk", "
|
|
155
|
+
server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address against ChainHint's 54M+ labeled address database (12 chains). Returns risk score 0-100, risk level (clean/low/medium/high/critical/sanctioned), entity name and category, labels, and sanctions hit. Use it to decide allow/warn/block before paying or interacting with a counterparty wallet. Free: 3 checks per day without a key; CHAINHINT_API_KEY (Agency plan) lifts it to 10,000/day. For a deeper report (risk factors, exposure, balance) use lookup_address.", {
|
|
89
156
|
address: z.string().describe("Wallet address to check (EVM 0x..., Bitcoin, or Solana)"),
|
|
90
157
|
chain: z.string().optional().describe("Blockchain: ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, solana, bitcoin (default: auto-detect from address format)"),
|
|
91
158
|
}, async ({ address, chain }) => {
|
|
@@ -93,41 +160,53 @@ server.tool("check_wallet_risk", "Check the risk score and labels for a crypto w
|
|
|
93
160
|
const params = { address };
|
|
94
161
|
if (chain)
|
|
95
162
|
params.chain = chain;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return { content: [{ type: "text", text: `Error: ${data.error ?? "Unknown error"}` }] };
|
|
99
|
-
}
|
|
100
|
-
const d = data.data;
|
|
101
|
-
const risk = d.risk;
|
|
102
|
-
const entity = d.entity;
|
|
103
|
-
const sanctions = d.sanctions;
|
|
104
|
-
const stats = d.stats;
|
|
163
|
+
// wallet-reputation returns a flat object (no {success,data} wrapper).
|
|
164
|
+
const { body: d, headers } = await apiGet("/wallet-reputation", params);
|
|
105
165
|
const lines = [
|
|
106
166
|
`## Wallet Risk Report: ${truncateAddr(d.address)}`,
|
|
167
|
+
`**Address:** ${d.address}`,
|
|
107
168
|
`**Chain:** ${d.chain}`,
|
|
108
|
-
`**Risk Score:** ${risk?.score ?? "N/A"}/100 — **${risk?.level ?? formatRiskLevel(risk?.score ?? 0)}**`,
|
|
109
169
|
];
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
170
|
+
// Canon (same as chainhint.com and the TG bot): an address that is not in
|
|
171
|
+
// the labeled database has NO DATA — that is not evidence it is clean.
|
|
172
|
+
// The API still returns risk_score 0 / "clean" for not-found, so the
|
|
173
|
+
// wording is fixed here, and public incidents are cross-checked so a
|
|
174
|
+
// known hack attacker that never got an `addresses` row is not
|
|
175
|
+
// presented as unknown.
|
|
176
|
+
if (!d.found_in_db) {
|
|
177
|
+
lines.push(`**Risk:** ⚪ NO DATA — address is not in ChainHint's labeled database. This is not evidence it is clean.`);
|
|
178
|
+
const inc = await findPublicIncidentByAttacker(d.address);
|
|
179
|
+
if (inc) {
|
|
180
|
+
lines.push(`⚠️ **Known attacker in a public hack incident:** ${inc.title ?? "Unnamed incident"} (${inc.chain}, loss ${usd(inc.amount_usd ?? inc.estimated_loss_usd)}${inc.risk_score != null ? `, incident risk ${inc.risk_score}/100` : ""}). Treat as HIGH risk.`, `🔗 https://chainhint.com/incident/${inc.id}`);
|
|
181
|
+
}
|
|
182
|
+
lines.push(`Use lookup_address for an on-chain assessment (risk factors, GoPlus flags, counterparty exposure).`);
|
|
114
183
|
}
|
|
115
184
|
else {
|
|
116
|
-
lines.push(`**
|
|
185
|
+
lines.push(`**Risk Score:** ${d.risk_score}/100 — **${(d.risk_level ?? formatRiskLevel(d.risk_score)).toUpperCase()}**`);
|
|
117
186
|
}
|
|
118
|
-
if (sanctions?.
|
|
119
|
-
lines.push(`⛔ **SANCTIONED
|
|
187
|
+
if (d.sanctions?.hit) {
|
|
188
|
+
lines.push(`⛔ **SANCTIONED / OFAC-linked**`);
|
|
120
189
|
}
|
|
121
|
-
if (
|
|
122
|
-
|
|
190
|
+
if (d.entity?.name) {
|
|
191
|
+
const sub = d.entity.subcategory ? ` / ${d.entity.subcategory}` : "";
|
|
192
|
+
const ver = d.entity.verified ? ", verified" : "";
|
|
193
|
+
lines.push(`**Entity:** ${d.entity.name} (${d.entity.category}${sub}${ver})`);
|
|
123
194
|
}
|
|
124
|
-
if (
|
|
125
|
-
lines.push(`**
|
|
126
|
-
if (stats.first_seen)
|
|
127
|
-
lines.push(`**First seen:** ${stats.first_seen.slice(0, 10)}`);
|
|
128
|
-
if (stats.last_seen)
|
|
129
|
-
lines.push(`**Last seen:** ${stats.last_seen.slice(0, 10)}`);
|
|
195
|
+
else if (d.category) {
|
|
196
|
+
lines.push(`**Category:** ${d.category}`);
|
|
130
197
|
}
|
|
198
|
+
else {
|
|
199
|
+
lines.push(`**Entity:** Unknown / unlabeled`);
|
|
200
|
+
}
|
|
201
|
+
if (d.labels?.length)
|
|
202
|
+
lines.push(`**Labels:** ${[...new Set(d.labels)].join(", ")}`);
|
|
203
|
+
if (d.is_contract != null)
|
|
204
|
+
lines.push(`**Type:** ${d.is_contract ? "Smart Contract" : "EOA (wallet)"}`);
|
|
205
|
+
lines.push(`**In ChainHint DB:** ${d.found_in_db ? "yes" : "no"}${d.sources?.length ? ` (sources: ${d.sources.join(", ")})` : ""}`);
|
|
206
|
+
lines.push(`**Checked at:** ${d.checked_at}`);
|
|
207
|
+
const quota = quotaLine(headers, "checks");
|
|
208
|
+
if (quota)
|
|
209
|
+
lines.push(``, quota);
|
|
131
210
|
lines.push(`\n*Powered by ChainHint — chainhint.com*`);
|
|
132
211
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
133
212
|
}
|
|
@@ -136,57 +215,82 @@ server.tool("check_wallet_risk", "Check the risk score and labels for a crypto w
|
|
|
136
215
|
}
|
|
137
216
|
});
|
|
138
217
|
// ── Tool 2: lookup_address ────────────────────────────────────────────────────
|
|
139
|
-
server.tool("lookup_address", "
|
|
218
|
+
server.tool("lookup_address", "Detailed lookup of a blockchain address: entity attribution, risk score with the factors behind it, sanctions and GoPlus security flags, counterparty exposure (where funds came from / went to, by category with named entities), balance, token count and transaction count. Free: 10 lookups per day without a key; CHAINHINT_API_KEY lifts it. Supports EVM chains, Bitcoin, Solana, TRON, TON.", {
|
|
140
219
|
address: z.string().describe("Blockchain address to look up"),
|
|
141
|
-
chain: z.string().optional().describe("Blockchain (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, solana, bitcoin)"),
|
|
220
|
+
chain: z.string().optional().describe("Blockchain (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, solana, bitcoin, tron, ton)"),
|
|
142
221
|
}, async ({ address, chain }) => {
|
|
143
222
|
try {
|
|
144
223
|
const params = { address };
|
|
145
224
|
if (chain)
|
|
146
225
|
params.chain = chain;
|
|
147
|
-
const data = await apiGet("/address-lookup", params);
|
|
226
|
+
const { body: data, headers: lookupHeaders } = await apiGet("/address-lookup", params);
|
|
148
227
|
if (!data.success || !data.data) {
|
|
149
228
|
return { content: [{ type: "text", text: `Error: ${data.error ?? "Unknown error"}` }] };
|
|
150
229
|
}
|
|
151
230
|
const d = data.data;
|
|
152
231
|
const lines = [
|
|
153
232
|
`## Address Lookup: ${truncateAddr(d.address)}`,
|
|
233
|
+
`**Address:** ${d.address}`,
|
|
154
234
|
`**Chain:** ${d.chain}`,
|
|
155
235
|
`**Type:** ${d.is_contract ? "Smart Contract" : "EOA (wallet)"}`,
|
|
156
236
|
];
|
|
157
|
-
if (d.entity)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
lines.push(`**
|
|
161
|
-
if (d.category)
|
|
162
|
-
lines.push(`**Category:** ${d.category}`);
|
|
163
|
-
if (d.balance)
|
|
164
|
-
lines.push(`**Balance:** ${d.balance}`);
|
|
165
|
-
const score = d.risk?.score;
|
|
166
|
-
if (score !== undefined) {
|
|
167
|
-
lines.push(`**Risk Score:** ${score}/100 — ${formatRiskLevel(score)}`);
|
|
237
|
+
if (d.entity?.name) {
|
|
238
|
+
const sub = d.entity.subcategory ? ` / ${d.entity.subcategory}` : "";
|
|
239
|
+
const conf = d.entity.confidence != null ? `, confidence ${Math.round(d.entity.confidence * 100)}%` : "";
|
|
240
|
+
lines.push(`**Entity:** ${d.entity.name} (${d.entity.category}${sub}${conf})`);
|
|
168
241
|
}
|
|
169
|
-
|
|
170
|
-
lines.push(`**
|
|
242
|
+
else {
|
|
243
|
+
lines.push(`**Entity:** Unknown / unlabeled`);
|
|
244
|
+
}
|
|
245
|
+
if (d.labels?.length)
|
|
246
|
+
lines.push(`**Labels:** ${[...new Set(d.labels)].join(", ")}`);
|
|
247
|
+
if (d.risk) {
|
|
248
|
+
const level = (d.risk.level ?? formatRiskLevel(d.risk.score)).toUpperCase();
|
|
249
|
+
lines.push(`**Risk Score:** ${d.risk.score}/100 — **${level}**`);
|
|
250
|
+
const factors = Object.entries(d.risk.factors ?? {}).filter(([, v]) => v).map(([k]) => k);
|
|
251
|
+
if (factors.length)
|
|
252
|
+
lines.push(`**Risk Factors:** ${factors.join(", ")}`);
|
|
253
|
+
if (d.risk.details?.length)
|
|
254
|
+
lines.push(`**Risk Details:** ${d.risk.details.join("; ")}`);
|
|
171
255
|
}
|
|
172
|
-
|
|
173
|
-
|
|
256
|
+
const sanctionIds = d.sanctions?.identifications ?? [];
|
|
257
|
+
if (sanctionIds.length) {
|
|
258
|
+
const names = sanctionIds.map((s) => [s.name, s.program].filter(Boolean).join(" / ")).filter(Boolean);
|
|
259
|
+
lines.push(`⛔ **SANCTIONED** — ${names.join("; ") || `${sanctionIds.length} identification(s)`}`);
|
|
174
260
|
}
|
|
175
|
-
|
|
261
|
+
const goplusFlags = Object.entries(d.goplus ?? {})
|
|
262
|
+
.filter(([k, v]) => v === "1" && k !== "contract_address")
|
|
263
|
+
.map(([k]) => k);
|
|
264
|
+
if (goplusFlags.length)
|
|
265
|
+
lines.push(`**GoPlus Security Flags:** ${goplusFlags.join(", ")}`);
|
|
266
|
+
if (d.balance_usd != null)
|
|
267
|
+
lines.push(`**Balance:** ${usd(d.balance_usd)}${d.tokens?.length ? ` across ${d.tokens.length} asset(s)` : ""}`);
|
|
268
|
+
if (d.tx_count != null)
|
|
176
269
|
lines.push(`**Transactions:** ${d.tx_count.toLocaleString()}`);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
270
|
+
const ex = d.exposure;
|
|
271
|
+
if (ex && (ex.inflow?.length || ex.outflow?.length)) {
|
|
272
|
+
lines.push(``, `### Counterparty Exposure (${ex.window ?? "recent transfers"})`);
|
|
273
|
+
if (ex.risk?.level) {
|
|
274
|
+
lines.push(`**Exposure Risk:** ${ex.risk.level.toUpperCase()} (${ex.risk.pct}% of volume to/from risky counterparties)`);
|
|
275
|
+
if (ex.risk.details?.length)
|
|
276
|
+
lines.push(...ex.risk.details.map((s) => `- ${s}`));
|
|
277
|
+
}
|
|
278
|
+
lines.push(`**Volume:** in ${usd(ex.total_in_usd)}, out ${usd(ex.total_out_usd)}; ${ex.counterparties_identified ?? 0}/${ex.counterparties_total ?? 0} counterparties identified`);
|
|
279
|
+
lines.push(...formatExposure("Inflow by category", ex.inflow));
|
|
280
|
+
lines.push(...formatExposure("Outflow by category", ex.outflow));
|
|
281
|
+
}
|
|
282
|
+
const lookupQuota = quotaLine(lookupHeaders, "lookups");
|
|
283
|
+
if (lookupQuota)
|
|
284
|
+
lines.push(``, lookupQuota);
|
|
285
|
+
lines.push(``, `🔗 https://chainhint.com/address/${d.address}?chain=${d.chain}`);
|
|
286
|
+
lines.push(`*Powered by ChainHint — chainhint.com*`);
|
|
182
287
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
183
288
|
}
|
|
184
289
|
catch (err) {
|
|
185
290
|
return { content: [{ type: "text", text: `Error looking up address: ${err.message}` }] };
|
|
186
291
|
}
|
|
187
292
|
});
|
|
188
|
-
|
|
189
|
-
server.tool("get_trace_status", "Get the fund trace status for a crypto hack incident. Returns how stolen funds moved — number of hops, total amount traced, known endpoints (exchanges, mixers, bridges), and current movement status (in_transit, mixing, reached_exchange, dormant). Useful for incident response and understanding where stolen funds went.", {
|
|
293
|
+
server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypto hack incident on ChainHint. Returns incident status, estimated loss, trace depth (hops) and graph size, where the stolen funds ended up (endpoints grouped by type: exchange, mixer, bridge, defi, unknown — with named entities and USD amounts), and the attacker's counterparty exposure. Look up by attacker address or ChainHint incident UUID. Works without an API key.", {
|
|
190
294
|
attacker_address: z.string().optional().describe("Attacker wallet address to look up incident by"),
|
|
191
295
|
incident_id: z.string().optional().describe("Incident UUID (from chainhint.com) — alternative to attacker_address"),
|
|
192
296
|
}, async ({ attacker_address, incident_id }) => {
|
|
@@ -194,21 +298,20 @@ server.tool("get_trace_status", "Get the fund trace status for a crypto hack inc
|
|
|
194
298
|
if (!attacker_address && !incident_id) {
|
|
195
299
|
return { content: [{ type: "text", text: "Error: provide either attacker_address or incident_id" }] };
|
|
196
300
|
}
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
301
|
+
// public_incidents_view is the canonical anonymous read path (SECURITY DEFINER,
|
|
302
|
+
// exposes only is_public rows and only public-safe columns).
|
|
303
|
+
const queryParams = {
|
|
304
|
+
select: "id,title,status,chain,attacker_address,amount_usd,estimated_loss_usd,risk_score,incident_type,hack_date,display_date,created_at,updated_at,source,endpoints,flow_graph,counterparty_exposure",
|
|
201
305
|
limit: "1",
|
|
202
306
|
order: "created_at.desc",
|
|
203
307
|
};
|
|
204
308
|
if (incident_id) {
|
|
205
309
|
queryParams["id"] = `eq.${incident_id}`;
|
|
206
|
-
delete queryParams["is_public"];
|
|
207
310
|
}
|
|
208
311
|
else if (attacker_address) {
|
|
209
|
-
queryParams["attacker_address"] = `eq.${attacker_address
|
|
312
|
+
queryParams["attacker_address"] = `eq.${normalizeAddr(attacker_address)}`;
|
|
210
313
|
}
|
|
211
|
-
const rows = await supabaseGet("
|
|
314
|
+
const rows = await supabaseGet("public_incidents_view", queryParams);
|
|
212
315
|
if (!rows?.length) {
|
|
213
316
|
return {
|
|
214
317
|
content: [{
|
|
@@ -221,25 +324,86 @@ server.tool("get_trace_status", "Get the fund trace status for a crypto hack inc
|
|
|
221
324
|
}
|
|
222
325
|
const inc = rows[0];
|
|
223
326
|
const lossUsd = inc.amount_usd ?? inc.estimated_loss_usd;
|
|
327
|
+
const date = inc.display_date ?? inc.hack_date ?? inc.created_at;
|
|
328
|
+
const status = (inc.status ?? "unknown").toLowerCase();
|
|
224
329
|
const lines = [
|
|
225
330
|
`## Fund Trace: ${inc.title ?? "Unnamed Incident"}`,
|
|
226
331
|
`**Incident ID:** ${inc.id}`,
|
|
227
332
|
`**Chain:** ${inc.chain}`,
|
|
228
|
-
`**Status:** ${
|
|
229
|
-
`**Attacker:** ${
|
|
230
|
-
`**Loss:** ${
|
|
231
|
-
`**Date:** ${
|
|
333
|
+
`**Status:** ${status.toUpperCase()}`,
|
|
334
|
+
`**Attacker:** ${inc.attacker_address}`,
|
|
335
|
+
`**Loss:** ${usd(lossUsd)}`,
|
|
336
|
+
`**Date:** ${date.slice(0, 10)}`,
|
|
232
337
|
];
|
|
233
|
-
if (inc.
|
|
234
|
-
lines.push(
|
|
338
|
+
if (inc.incident_type)
|
|
339
|
+
lines.push(`**Type:** ${inc.incident_type}`);
|
|
340
|
+
if (inc.risk_score != null)
|
|
341
|
+
lines.push(`**Risk Score:** ${inc.risk_score}/100`);
|
|
342
|
+
if (inc.source)
|
|
343
|
+
lines.push(`**Source:** ${inc.source}`);
|
|
344
|
+
if (inc.updated_at)
|
|
345
|
+
lines.push(`**Last traced:** ${inc.updated_at.slice(0, 10)}`);
|
|
346
|
+
const edges = inc.flow_graph?.edges ?? [];
|
|
347
|
+
const nodes = inc.flow_graph?.nodes ?? [];
|
|
348
|
+
if (edges.length) {
|
|
349
|
+
const maxDepth = edges.reduce((m, e) => Math.max(m, e.depth ?? 0), 0);
|
|
350
|
+
lines.push(``, `### Trace Graph`);
|
|
351
|
+
lines.push(`**Hops traced:** ${maxDepth} · **Addresses:** ${nodes.length} · **Transfers:** ${edges.length}`);
|
|
352
|
+
}
|
|
353
|
+
const endpoints = inc.endpoints ?? [];
|
|
354
|
+
if (endpoints.length) {
|
|
355
|
+
const byType = new Map();
|
|
356
|
+
for (const e of endpoints) {
|
|
357
|
+
const t = e.type ?? "unknown";
|
|
358
|
+
const b = byType.get(t) ?? { usd: 0, n: 0, entities: new Map() };
|
|
359
|
+
b.usd += e.amount_usd ?? 0;
|
|
360
|
+
b.n += 1;
|
|
361
|
+
const name = e.entity_name ?? e.entity;
|
|
362
|
+
if (name)
|
|
363
|
+
b.entities.set(name, (b.entities.get(name) ?? 0) + (e.amount_usd ?? 0));
|
|
364
|
+
byType.set(t, b);
|
|
365
|
+
}
|
|
366
|
+
const totalUsd = [...byType.values()].reduce((s, b) => s + b.usd, 0);
|
|
367
|
+
lines.push(``, `### Where the funds went (${endpoints.length} endpoints, ${usd(totalUsd)} tracked)`);
|
|
368
|
+
for (const [t, b] of [...byType.entries()].sort((a, b) => b[1].usd - a[1].usd)) {
|
|
369
|
+
const top = [...b.entities.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([n]) => n);
|
|
370
|
+
const pct = totalUsd > 0 ? ` (${((b.usd / totalUsd) * 100).toFixed(1)}%)` : "";
|
|
371
|
+
lines.push(`- **${t}**: ${usd(b.usd)}${pct} across ${b.n} address(es)${top.length ? ` — ${top.join(", ")}` : ""}`);
|
|
372
|
+
}
|
|
373
|
+
const topEndpoints = [...endpoints]
|
|
374
|
+
.filter((e) => (e.amount_usd ?? 0) > 0)
|
|
375
|
+
.sort((a, b) => (b.amount_usd ?? 0) - (a.amount_usd ?? 0))
|
|
376
|
+
.slice(0, 5);
|
|
377
|
+
if (topEndpoints.length) {
|
|
378
|
+
lines.push(`**Largest endpoints:**`);
|
|
379
|
+
for (const e of topEndpoints) {
|
|
380
|
+
const name = e.entity_name ?? e.entity ?? "unattributed";
|
|
381
|
+
lines.push(`- ${truncateAddr(e.address)} — ${name} (${e.type ?? "unknown"}): ${usd(e.amount_usd)}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const cx = inc.counterparty_exposure;
|
|
386
|
+
if (cx?.risk?.level || cx?.outflow?.length || cx?.inflow?.length) {
|
|
387
|
+
lines.push(``, `### Attacker Counterparty Exposure`);
|
|
388
|
+
if (cx.risk?.level) {
|
|
389
|
+
lines.push(`**Exposure Risk:** ${cx.risk.level.toUpperCase()} (${cx.risk.pct}%)`);
|
|
390
|
+
if (cx.risk.details?.length)
|
|
391
|
+
lines.push(...cx.risk.details.slice(0, 5).map((s) => `- ${s}`));
|
|
392
|
+
}
|
|
393
|
+
lines.push(...formatExposure("Outflow by category", cx.outflow));
|
|
394
|
+
lines.push(...formatExposure("Inflow by category", cx.inflow));
|
|
395
|
+
}
|
|
396
|
+
lines.push(``);
|
|
397
|
+
if (status === "traced") {
|
|
398
|
+
lines.push(`✅ Trace complete — full flow graph and counterparty exposure on ChainHint.`);
|
|
235
399
|
}
|
|
236
|
-
else if (
|
|
237
|
-
lines.push(
|
|
400
|
+
else if (status === "analyzing") {
|
|
401
|
+
lines.push(`⏳ Trace in progress...`);
|
|
238
402
|
}
|
|
239
403
|
else {
|
|
240
|
-
lines.push(
|
|
404
|
+
lines.push(`⚠️ Status: ${status}`);
|
|
241
405
|
}
|
|
242
|
-
lines.push(
|
|
406
|
+
lines.push(`🔗 View full trace: https://chainhint.com/incident/${inc.id}`);
|
|
243
407
|
lines.push(`*Powered by ChainHint — chainhint.com*`);
|
|
244
408
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
245
409
|
}
|