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 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` | Risk score, entity label, sanctions check for any wallet |
10
- | `lookup_address` | Full address details — entity, category, tx history |
11
- | `get_trace_status` | Fund trace status for hack incidentswhere did the money go? |
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 incidenthops, endpoints by type (exchange/mixer/bridge/defi), exposure | Unlimited |
12
12
 
13
13
  ## Requirements
14
14
 
15
15
  - Node.js 18+
16
- - ChainHint Agency plan API key (`ch_live_...`) from [chainhint.com/settings](https://chainhint.com/settings)
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` | | API key from chainhint.com (Agency plan) |
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` | — | For get_trace_status (public incidents) |
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, category, transaction history)
11
- * - get_trace_status → incident fund trace status (flow graph summary)
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: set CHAINHINT_API_KEY env var (Agency plan API key: ch_live_...)
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, category, transaction history)
11
- * - get_trace_status → incident fund trace status (flow graph summary)
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: set CHAINHINT_API_KEY env var (Agency plan API key: ch_live_...)
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("[chainhint-mcp] ERROR: CHAINHINT_API_KEY is not set.");
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
- // ── HTTP helpers ──────────────────────────────────────────────────────────────
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
- const res = await fetch(url.toString(), {
36
- headers: {
37
- "X-Api-Key": API_KEY,
38
- "Authorization": `Bearer ${API_KEY}`,
39
- "Content-Type": "application/json",
40
- },
41
- });
42
- const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
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
- throw new Error(body?.error ?? `HTTP ${res.status}: ${url.toString()}`);
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: "1.0.0",
152
+ version: VERSION,
86
153
  });
87
154
  // ── Tool 1: check_wallet_risk ─────────────────────────────────────────────────
88
- server.tool("check_wallet_risk", "Check the risk score and labels for a crypto wallet address. Returns risk level (CLEAN/LOW/MEDIUM/HIGH/CRITICAL), entity label, category, sanctions status, and transaction statistics. Use this to assess whether a wallet is associated with hacks, scams, mixers, or sanctioned entities.", {
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
- const data = await apiGet("/wallet-reputation", params);
97
- if (!data.success || !data.data) {
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
- if (entity?.name) {
111
- lines.push(`**Entity:** ${entity.name} (${entity.category})`);
112
- if (entity.label)
113
- lines.push(`**Label:** ${entity.label}`);
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(`**Entity:** Unknown / unlabeled`);
185
+ lines.push(`**Risk Score:** ${d.risk_score}/100 — **${(d.risk_level ?? formatRiskLevel(d.risk_score)).toUpperCase()}**`);
117
186
  }
118
- if (sanctions?.is_sanctioned) {
119
- lines.push(`⛔ **SANCTIONED** Programs: ${sanctions.programs.join(", ")}`);
187
+ if (d.sanctions?.hit) {
188
+ lines.push(`⛔ **SANCTIONED / OFAC-linked**`);
120
189
  }
121
- if (risk?.flags?.length) {
122
- lines.push(`**Risk Flags:** ${risk.flags.join(", ")}`);
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 (stats) {
125
- lines.push(`**Transactions:** ${stats.tx_count?.toLocaleString() ?? "N/A"}`);
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", "Look up detailed information about a blockchain address including entity label, risk assessment, transaction history, and known associations. More detailed than check_wallet_risk includes transaction counts, token holdings summary, and entity metadata.", {
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
- lines.push(`**Entity:** ${d.entity}`);
159
- if (d.label)
160
- lines.push(`**Label:** ${d.label}`);
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
- if (d.risk?.flags?.length) {
170
- lines.push(`**Flags:** ${d.risk.flags.join(", ")}`);
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
- if (d.sanctions?.is_sanctioned) {
173
- lines.push(`⛔ **SANCTIONED**`);
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
- if (d.tx_count)
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
- if (d.first_seen)
178
- lines.push(`**First seen:** ${d.first_seen.slice(0, 10)}`);
179
- if (d.last_seen)
180
- lines.push(`**Last seen:** ${d.last_seen.slice(0, 10)}`);
181
- lines.push(`\n*Powered by ChainHint chainhint.com*`);
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
- // ── Tool 3: get_trace_status ──────────────────────────────────────────────────
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
- // Query public incidents via Supabase REST
198
- let queryParams = {
199
- select: "id,title,status,chain,attacker_address,amount_usd,estimated_loss_usd,created_at,source",
200
- is_public: "eq.true",
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.toLowerCase()}`;
312
+ queryParams["attacker_address"] = `eq.${normalizeAddr(attacker_address)}`;
210
313
  }
211
- const rows = await supabaseGet("incidents", queryParams);
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:** ${inc.status.toUpperCase()}`,
229
- `**Attacker:** ${truncateAddr(inc.attacker_address)}`,
230
- `**Loss:** ${lossUsd ? `$${(lossUsd / 1_000_000).toFixed(2)}M` : "Unknown"}`,
231
- `**Date:** ${inc.created_at.slice(0, 10)}`,
333
+ `**Status:** ${status.toUpperCase()}`,
334
+ `**Attacker:** ${inc.attacker_address}`,
335
+ `**Loss:** ${usd(lossUsd)}`,
336
+ `**Date:** ${date.slice(0, 10)}`,
232
337
  ];
233
- if (inc.status.toLowerCase() === "traced") {
234
- lines.push(`\n✅ Trace complete — view full flow graph and counterparty exposure on ChainHint.`);
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 (inc.status.toLowerCase() === "analyzing") {
237
- lines.push(`\n⏳ Trace in progress...`);
400
+ else if (status === "analyzing") {
401
+ lines.push(`⏳ Trace in progress...`);
238
402
  }
239
403
  else {
240
- lines.push(`\n⚠️ Status: ${inc.status}`);
404
+ lines.push(`⚠️ Status: ${status}`);
241
405
  }
242
- lines.push(`\n🔗 View full trace: https://chainhint.com/incident/${inc.id}`);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chainhint-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "ChainHint MCP server — crypto risk intelligence tools for Claude Desktop and Cursor",
5
5
  "license": "MIT",
6
6
  "repository": {