chainhint-mcp 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/index.d.ts +5 -4
- package/dist/index.js +194 -89
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,14 +6,14 @@ Crypto risk intelligence for Claude Desktop, Cursor, and any MCP-compatible AI.
|
|
|
6
6
|
|
|
7
7
|
| Tool | Description |
|
|
8
8
|
|------|-------------|
|
|
9
|
-
| `check_wallet_risk` |
|
|
10
|
-
| `lookup_address` |
|
|
11
|
-
| `get_trace_status` | Fund
|
|
9
|
+
| `check_wallet_risk` | Fast risk score, entity, labels, sanctions hit (54M+ labeled addresses). **Needs API key** |
|
|
10
|
+
| `lookup_address` | Deep address report — entity, risk factors, GoPlus flags, counterparty exposure, balance. No key needed |
|
|
11
|
+
| `get_trace_status` | Fund-trace summary for a public hack incident — hops, endpoints by type (exchange/mixer/bridge/defi), exposure. No key needed |
|
|
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
|
+
- Optional: ChainHint Agency plan API key (`ch_live_...`) from [chainhint.com/settings](https://chainhint.com/settings) — only `check_wallet_risk` needs it
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
@@ -75,9 +75,9 @@ npm run dev
|
|
|
75
75
|
|
|
76
76
|
| Variable | Required | Description |
|
|
77
77
|
|----------|----------|-------------|
|
|
78
|
-
| `CHAINHINT_API_KEY` |
|
|
78
|
+
| `CHAINHINT_API_KEY` | — | API key from chainhint.com (Agency plan). Required only for `check_wallet_risk` |
|
|
79
79
|
| `CHAINHINT_API_URL` | — | Override API base URL (default: production) |
|
|
80
|
-
| `CHAINHINT_SUPABASE_ANON_KEY` | — |
|
|
80
|
+
| `CHAINHINT_SUPABASE_ANON_KEY` | — | Override anon key for get_trace_status (public incidents) |
|
|
81
81
|
|
|
82
82
|
## Example prompts
|
|
83
83
|
|
package/dist/index.d.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
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: CHAINHINT_API_KEY env var (Agency plan API key: ch_live_...) is only
|
|
14
|
+
* required for check_wallet_risk. The other two tools hit public endpoints.
|
|
14
15
|
*/
|
|
15
16
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -6,11 +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: CHAINHINT_API_KEY env var (Agency plan API key: ch_live_...) is only
|
|
14
|
+
* required for check_wallet_risk. The other two tools hit public endpoints.
|
|
14
15
|
*/
|
|
15
16
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
17
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -20,26 +21,26 @@ const API_KEY = process.env.CHAINHINT_API_KEY;
|
|
|
20
21
|
const BASE_URL = process.env.CHAINHINT_API_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co/functions/v1";
|
|
21
22
|
const SUPABASE_URL = process.env.CHAINHINT_SUPABASE_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co";
|
|
22
23
|
const SUPABASE_ANON_KEY = process.env.CHAINHINT_SUPABASE_ANON_KEY ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtqaXdmd3ltbnV6eHJpb2toY2prIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI3MzkxODgsImV4cCI6MjA4ODMxNTE4OH0.VqzzF_jI8zF072cbjWEDbYo3PnMDlIPy621iWkXEqyo";
|
|
24
|
+
const NO_KEY_MESSAGE = "check_wallet_risk requires CHAINHINT_API_KEY (Agency plan, ch_live_...). " +
|
|
25
|
+
"Get one at chainhint.com → Settings → API Keys, or use lookup_address which needs no key.";
|
|
23
26
|
if (!API_KEY) {
|
|
24
|
-
console.error("[chainhint-mcp]
|
|
25
|
-
console.error("
|
|
26
|
-
process.exit(1);
|
|
27
|
+
console.error("[chainhint-mcp] WARN: CHAINHINT_API_KEY is not set — check_wallet_risk will be unavailable.");
|
|
28
|
+
console.error(" lookup_address and get_trace_status work without a key.");
|
|
27
29
|
}
|
|
28
30
|
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
29
|
-
async function apiGet(path, params
|
|
31
|
+
async function apiGet(path, params, opts) {
|
|
30
32
|
const url = new URL(`${BASE_URL}${path}`);
|
|
31
33
|
for (const [k, v] of Object.entries(params)) {
|
|
32
34
|
if (v)
|
|
33
35
|
url.searchParams.set(k, v);
|
|
34
36
|
}
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
});
|
|
42
|
-
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
|
37
|
+
const headers = { "Content-Type": "application/json" };
|
|
38
|
+
if (opts.auth && API_KEY) {
|
|
39
|
+
headers["X-Api-Key"] = API_KEY;
|
|
40
|
+
headers["Authorization"] = `Bearer ${API_KEY}`;
|
|
41
|
+
}
|
|
42
|
+
const res = await fetch(url.toString(), { headers });
|
|
43
|
+
const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` })));
|
|
43
44
|
if (!res.ok) {
|
|
44
45
|
throw new Error(body?.error ?? `HTTP ${res.status}: ${url.toString()}`);
|
|
45
46
|
}
|
|
@@ -54,7 +55,6 @@ async function supabaseGet(table, params) {
|
|
|
54
55
|
headers: {
|
|
55
56
|
"apikey": SUPABASE_ANON_KEY,
|
|
56
57
|
"Authorization": `Bearer ${SUPABASE_ANON_KEY}`,
|
|
57
|
-
"X-Api-Key": API_KEY,
|
|
58
58
|
"Accept": "application/json",
|
|
59
59
|
},
|
|
60
60
|
});
|
|
@@ -79,55 +79,78 @@ function formatRiskLevel(score) {
|
|
|
79
79
|
function truncateAddr(addr) {
|
|
80
80
|
return addr.length > 12 ? `${addr.slice(0, 8)}...${addr.slice(-6)}` : addr;
|
|
81
81
|
}
|
|
82
|
+
function usd(n) {
|
|
83
|
+
if (n == null || !Number.isFinite(n))
|
|
84
|
+
return "Unknown";
|
|
85
|
+
if (Math.abs(n) >= 1_000_000)
|
|
86
|
+
return `$${(n / 1_000_000).toFixed(2)}M`;
|
|
87
|
+
if (Math.abs(n) >= 1_000)
|
|
88
|
+
return `$${(n / 1_000).toFixed(1)}K`;
|
|
89
|
+
return `$${n.toFixed(2)}`;
|
|
90
|
+
}
|
|
91
|
+
// EVM addresses are case-insensitive and stored lowercase; base58 chains
|
|
92
|
+
// (BTC/TRON/SOL/TON) are case-sensitive — never lowercase those.
|
|
93
|
+
function normalizeAddr(addr) {
|
|
94
|
+
const a = addr.trim();
|
|
95
|
+
return a.startsWith("0x") ? a.toLowerCase() : a;
|
|
96
|
+
}
|
|
97
|
+
function formatExposure(label, buckets) {
|
|
98
|
+
if (!buckets?.length)
|
|
99
|
+
return [];
|
|
100
|
+
const sorted = [...buckets].sort((a, b) => (b.usd ?? 0) - (a.usd ?? 0)).slice(0, 5);
|
|
101
|
+
return [
|
|
102
|
+
`**${label}:**`,
|
|
103
|
+
...sorted.map((b) => {
|
|
104
|
+
const who = b.top_entities?.length ? ` — ${b.top_entities.slice(0, 3).join(", ")}` : "";
|
|
105
|
+
return `- ${b.category}: ${usd(b.usd)} (${b.pct}%, ${b.counterparties} counterpart${b.counterparties === 1 ? "y" : "ies"})${who}`;
|
|
106
|
+
}),
|
|
107
|
+
];
|
|
108
|
+
}
|
|
82
109
|
// ── MCP Server ────────────────────────────────────────────────────────────────
|
|
83
110
|
const server = new McpServer({
|
|
84
111
|
name: "chainhint",
|
|
85
|
-
version: "1.0.
|
|
112
|
+
version: "1.0.1",
|
|
86
113
|
});
|
|
87
114
|
// ── Tool 1: check_wallet_risk ─────────────────────────────────────────────────
|
|
88
|
-
server.tool("check_wallet_risk", "
|
|
115
|
+
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. Requires CHAINHINT_API_KEY (Agency plan). For a keyless, deeper look (risk factors, exposure, balance) use lookup_address.", {
|
|
89
116
|
address: z.string().describe("Wallet address to check (EVM 0x..., Bitcoin, or Solana)"),
|
|
90
117
|
chain: z.string().optional().describe("Blockchain: ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, solana, bitcoin (default: auto-detect from address format)"),
|
|
91
118
|
}, async ({ address, chain }) => {
|
|
119
|
+
if (!API_KEY) {
|
|
120
|
+
return { content: [{ type: "text", text: NO_KEY_MESSAGE }] };
|
|
121
|
+
}
|
|
92
122
|
try {
|
|
93
123
|
const params = { address };
|
|
94
124
|
if (chain)
|
|
95
125
|
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;
|
|
126
|
+
// wallet-reputation returns a flat object (no {success,data} wrapper).
|
|
127
|
+
const d = await apiGet("/wallet-reputation", params, { auth: true });
|
|
105
128
|
const lines = [
|
|
106
129
|
`## Wallet Risk Report: ${truncateAddr(d.address)}`,
|
|
130
|
+
`**Address:** ${d.address}`,
|
|
107
131
|
`**Chain:** ${d.chain}`,
|
|
108
|
-
`**Risk Score:** ${
|
|
132
|
+
`**Risk Score:** ${d.risk_score}/100 — **${(d.risk_level ?? formatRiskLevel(d.risk_score)).toUpperCase()}**`,
|
|
109
133
|
];
|
|
110
|
-
if (
|
|
111
|
-
lines.push(
|
|
112
|
-
if (entity.label)
|
|
113
|
-
lines.push(`**Label:** ${entity.label}`);
|
|
134
|
+
if (d.sanctions?.hit) {
|
|
135
|
+
lines.push(`⛔ **SANCTIONED / OFAC-linked**`);
|
|
114
136
|
}
|
|
115
|
-
|
|
116
|
-
|
|
137
|
+
if (d.entity?.name) {
|
|
138
|
+
const sub = d.entity.subcategory ? ` / ${d.entity.subcategory}` : "";
|
|
139
|
+
const ver = d.entity.verified ? ", verified" : "";
|
|
140
|
+
lines.push(`**Entity:** ${d.entity.name} (${d.entity.category}${sub}${ver})`);
|
|
117
141
|
}
|
|
118
|
-
if (
|
|
119
|
-
lines.push(
|
|
120
|
-
}
|
|
121
|
-
if (risk?.flags?.length) {
|
|
122
|
-
lines.push(`**Risk Flags:** ${risk.flags.join(", ")}`);
|
|
142
|
+
else if (d.category) {
|
|
143
|
+
lines.push(`**Category:** ${d.category}`);
|
|
123
144
|
}
|
|
124
|
-
|
|
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)}`);
|
|
145
|
+
else {
|
|
146
|
+
lines.push(`**Entity:** Unknown / unlabeled`);
|
|
130
147
|
}
|
|
148
|
+
if (d.labels?.length)
|
|
149
|
+
lines.push(`**Labels:** ${[...new Set(d.labels)].join(", ")}`);
|
|
150
|
+
if (d.is_contract != null)
|
|
151
|
+
lines.push(`**Type:** ${d.is_contract ? "Smart Contract" : "EOA (wallet)"}`);
|
|
152
|
+
lines.push(`**In ChainHint DB:** ${d.found_in_db ? "yes" : "no"}${d.sources?.length ? ` (sources: ${d.sources.join(", ")})` : ""}`);
|
|
153
|
+
lines.push(`**Checked at:** ${d.checked_at}`);
|
|
131
154
|
lines.push(`\n*Powered by ChainHint — chainhint.com*`);
|
|
132
155
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
133
156
|
}
|
|
@@ -136,57 +159,79 @@ server.tool("check_wallet_risk", "Check the risk score and labels for a crypto w
|
|
|
136
159
|
}
|
|
137
160
|
});
|
|
138
161
|
// ── Tool 2: lookup_address ────────────────────────────────────────────────────
|
|
139
|
-
server.tool("lookup_address", "
|
|
162
|
+
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. Works without an API key. Supports EVM chains, Bitcoin, Solana, TRON, TON.", {
|
|
140
163
|
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)"),
|
|
164
|
+
chain: z.string().optional().describe("Blockchain (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, solana, bitcoin, tron, ton)"),
|
|
142
165
|
}, async ({ address, chain }) => {
|
|
143
166
|
try {
|
|
144
167
|
const params = { address };
|
|
145
168
|
if (chain)
|
|
146
169
|
params.chain = chain;
|
|
147
|
-
const data = await apiGet("/address-lookup", params);
|
|
170
|
+
const data = await apiGet("/address-lookup", params, { auth: false });
|
|
148
171
|
if (!data.success || !data.data) {
|
|
149
172
|
return { content: [{ type: "text", text: `Error: ${data.error ?? "Unknown error"}` }] };
|
|
150
173
|
}
|
|
151
174
|
const d = data.data;
|
|
152
175
|
const lines = [
|
|
153
176
|
`## Address Lookup: ${truncateAddr(d.address)}`,
|
|
177
|
+
`**Address:** ${d.address}`,
|
|
154
178
|
`**Chain:** ${d.chain}`,
|
|
155
179
|
`**Type:** ${d.is_contract ? "Smart Contract" : "EOA (wallet)"}`,
|
|
156
180
|
];
|
|
157
|
-
if (d.entity)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
lines.push(`**
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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)}`);
|
|
181
|
+
if (d.entity?.name) {
|
|
182
|
+
const sub = d.entity.subcategory ? ` / ${d.entity.subcategory}` : "";
|
|
183
|
+
const conf = d.entity.confidence != null ? `, confidence ${Math.round(d.entity.confidence * 100)}%` : "";
|
|
184
|
+
lines.push(`**Entity:** ${d.entity.name} (${d.entity.category}${sub}${conf})`);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
lines.push(`**Entity:** Unknown / unlabeled`);
|
|
168
188
|
}
|
|
169
|
-
if (d.
|
|
170
|
-
lines.push(`**
|
|
189
|
+
if (d.labels?.length)
|
|
190
|
+
lines.push(`**Labels:** ${[...new Set(d.labels)].join(", ")}`);
|
|
191
|
+
if (d.risk) {
|
|
192
|
+
const level = (d.risk.level ?? formatRiskLevel(d.risk.score)).toUpperCase();
|
|
193
|
+
lines.push(`**Risk Score:** ${d.risk.score}/100 — **${level}**`);
|
|
194
|
+
const factors = Object.entries(d.risk.factors ?? {}).filter(([, v]) => v).map(([k]) => k);
|
|
195
|
+
if (factors.length)
|
|
196
|
+
lines.push(`**Risk Factors:** ${factors.join(", ")}`);
|
|
197
|
+
if (d.risk.details?.length)
|
|
198
|
+
lines.push(`**Risk Details:** ${d.risk.details.join("; ")}`);
|
|
171
199
|
}
|
|
172
|
-
|
|
173
|
-
|
|
200
|
+
const sanctionIds = d.sanctions?.identifications ?? [];
|
|
201
|
+
if (sanctionIds.length) {
|
|
202
|
+
const names = sanctionIds.map((s) => [s.name, s.program].filter(Boolean).join(" / ")).filter(Boolean);
|
|
203
|
+
lines.push(`⛔ **SANCTIONED** — ${names.join("; ") || `${sanctionIds.length} identification(s)`}`);
|
|
174
204
|
}
|
|
175
|
-
|
|
205
|
+
const goplusFlags = Object.entries(d.goplus ?? {})
|
|
206
|
+
.filter(([k, v]) => v === "1" && k !== "contract_address")
|
|
207
|
+
.map(([k]) => k);
|
|
208
|
+
if (goplusFlags.length)
|
|
209
|
+
lines.push(`**GoPlus Security Flags:** ${goplusFlags.join(", ")}`);
|
|
210
|
+
if (d.balance_usd != null)
|
|
211
|
+
lines.push(`**Balance:** ${usd(d.balance_usd)}${d.tokens?.length ? ` across ${d.tokens.length} asset(s)` : ""}`);
|
|
212
|
+
if (d.tx_count != null)
|
|
176
213
|
lines.push(`**Transactions:** ${d.tx_count.toLocaleString()}`);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
214
|
+
const ex = d.exposure;
|
|
215
|
+
if (ex && (ex.inflow?.length || ex.outflow?.length)) {
|
|
216
|
+
lines.push(``, `### Counterparty Exposure (${ex.window ?? "recent transfers"})`);
|
|
217
|
+
if (ex.risk?.level) {
|
|
218
|
+
lines.push(`**Exposure Risk:** ${ex.risk.level.toUpperCase()} (${ex.risk.pct}% of volume to/from risky counterparties)`);
|
|
219
|
+
if (ex.risk.details?.length)
|
|
220
|
+
lines.push(...ex.risk.details.map((s) => `- ${s}`));
|
|
221
|
+
}
|
|
222
|
+
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`);
|
|
223
|
+
lines.push(...formatExposure("Inflow by category", ex.inflow));
|
|
224
|
+
lines.push(...formatExposure("Outflow by category", ex.outflow));
|
|
225
|
+
}
|
|
226
|
+
lines.push(``, `🔗 https://chainhint.com/address/${d.address}?chain=${d.chain}`);
|
|
227
|
+
lines.push(`*Powered by ChainHint — chainhint.com*`);
|
|
182
228
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
183
229
|
}
|
|
184
230
|
catch (err) {
|
|
185
231
|
return { content: [{ type: "text", text: `Error looking up address: ${err.message}` }] };
|
|
186
232
|
}
|
|
187
233
|
});
|
|
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.", {
|
|
234
|
+
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
235
|
attacker_address: z.string().optional().describe("Attacker wallet address to look up incident by"),
|
|
191
236
|
incident_id: z.string().optional().describe("Incident UUID (from chainhint.com) — alternative to attacker_address"),
|
|
192
237
|
}, async ({ attacker_address, incident_id }) => {
|
|
@@ -194,21 +239,20 @@ server.tool("get_trace_status", "Get the fund trace status for a crypto hack inc
|
|
|
194
239
|
if (!attacker_address && !incident_id) {
|
|
195
240
|
return { content: [{ type: "text", text: "Error: provide either attacker_address or incident_id" }] };
|
|
196
241
|
}
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
242
|
+
// public_incidents_view is the canonical anonymous read path (SECURITY DEFINER,
|
|
243
|
+
// exposes only is_public rows and only public-safe columns).
|
|
244
|
+
const queryParams = {
|
|
245
|
+
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
246
|
limit: "1",
|
|
202
247
|
order: "created_at.desc",
|
|
203
248
|
};
|
|
204
249
|
if (incident_id) {
|
|
205
250
|
queryParams["id"] = `eq.${incident_id}`;
|
|
206
|
-
delete queryParams["is_public"];
|
|
207
251
|
}
|
|
208
252
|
else if (attacker_address) {
|
|
209
|
-
queryParams["attacker_address"] = `eq.${attacker_address
|
|
253
|
+
queryParams["attacker_address"] = `eq.${normalizeAddr(attacker_address)}`;
|
|
210
254
|
}
|
|
211
|
-
const rows = await supabaseGet("
|
|
255
|
+
const rows = await supabaseGet("public_incidents_view", queryParams);
|
|
212
256
|
if (!rows?.length) {
|
|
213
257
|
return {
|
|
214
258
|
content: [{
|
|
@@ -221,25 +265,86 @@ server.tool("get_trace_status", "Get the fund trace status for a crypto hack inc
|
|
|
221
265
|
}
|
|
222
266
|
const inc = rows[0];
|
|
223
267
|
const lossUsd = inc.amount_usd ?? inc.estimated_loss_usd;
|
|
268
|
+
const date = inc.display_date ?? inc.hack_date ?? inc.created_at;
|
|
269
|
+
const status = (inc.status ?? "unknown").toLowerCase();
|
|
224
270
|
const lines = [
|
|
225
271
|
`## Fund Trace: ${inc.title ?? "Unnamed Incident"}`,
|
|
226
272
|
`**Incident ID:** ${inc.id}`,
|
|
227
273
|
`**Chain:** ${inc.chain}`,
|
|
228
|
-
`**Status:** ${
|
|
229
|
-
`**Attacker:** ${
|
|
230
|
-
`**Loss:** ${
|
|
231
|
-
`**Date:** ${
|
|
274
|
+
`**Status:** ${status.toUpperCase()}`,
|
|
275
|
+
`**Attacker:** ${inc.attacker_address}`,
|
|
276
|
+
`**Loss:** ${usd(lossUsd)}`,
|
|
277
|
+
`**Date:** ${date.slice(0, 10)}`,
|
|
232
278
|
];
|
|
233
|
-
if (inc.
|
|
234
|
-
lines.push(
|
|
279
|
+
if (inc.incident_type)
|
|
280
|
+
lines.push(`**Type:** ${inc.incident_type}`);
|
|
281
|
+
if (inc.risk_score != null)
|
|
282
|
+
lines.push(`**Risk Score:** ${inc.risk_score}/100`);
|
|
283
|
+
if (inc.source)
|
|
284
|
+
lines.push(`**Source:** ${inc.source}`);
|
|
285
|
+
if (inc.updated_at)
|
|
286
|
+
lines.push(`**Last traced:** ${inc.updated_at.slice(0, 10)}`);
|
|
287
|
+
const edges = inc.flow_graph?.edges ?? [];
|
|
288
|
+
const nodes = inc.flow_graph?.nodes ?? [];
|
|
289
|
+
if (edges.length) {
|
|
290
|
+
const maxDepth = edges.reduce((m, e) => Math.max(m, e.depth ?? 0), 0);
|
|
291
|
+
lines.push(``, `### Trace Graph`);
|
|
292
|
+
lines.push(`**Hops traced:** ${maxDepth} · **Addresses:** ${nodes.length} · **Transfers:** ${edges.length}`);
|
|
293
|
+
}
|
|
294
|
+
const endpoints = inc.endpoints ?? [];
|
|
295
|
+
if (endpoints.length) {
|
|
296
|
+
const byType = new Map();
|
|
297
|
+
for (const e of endpoints) {
|
|
298
|
+
const t = e.type ?? "unknown";
|
|
299
|
+
const b = byType.get(t) ?? { usd: 0, n: 0, entities: new Map() };
|
|
300
|
+
b.usd += e.amount_usd ?? 0;
|
|
301
|
+
b.n += 1;
|
|
302
|
+
const name = e.entity_name ?? e.entity;
|
|
303
|
+
if (name)
|
|
304
|
+
b.entities.set(name, (b.entities.get(name) ?? 0) + (e.amount_usd ?? 0));
|
|
305
|
+
byType.set(t, b);
|
|
306
|
+
}
|
|
307
|
+
const totalUsd = [...byType.values()].reduce((s, b) => s + b.usd, 0);
|
|
308
|
+
lines.push(``, `### Where the funds went (${endpoints.length} endpoints, ${usd(totalUsd)} tracked)`);
|
|
309
|
+
for (const [t, b] of [...byType.entries()].sort((a, b) => b[1].usd - a[1].usd)) {
|
|
310
|
+
const top = [...b.entities.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([n]) => n);
|
|
311
|
+
const pct = totalUsd > 0 ? ` (${((b.usd / totalUsd) * 100).toFixed(1)}%)` : "";
|
|
312
|
+
lines.push(`- **${t}**: ${usd(b.usd)}${pct} across ${b.n} address(es)${top.length ? ` — ${top.join(", ")}` : ""}`);
|
|
313
|
+
}
|
|
314
|
+
const topEndpoints = [...endpoints]
|
|
315
|
+
.filter((e) => (e.amount_usd ?? 0) > 0)
|
|
316
|
+
.sort((a, b) => (b.amount_usd ?? 0) - (a.amount_usd ?? 0))
|
|
317
|
+
.slice(0, 5);
|
|
318
|
+
if (topEndpoints.length) {
|
|
319
|
+
lines.push(`**Largest endpoints:**`);
|
|
320
|
+
for (const e of topEndpoints) {
|
|
321
|
+
const name = e.entity_name ?? e.entity ?? "unattributed";
|
|
322
|
+
lines.push(`- ${truncateAddr(e.address)} — ${name} (${e.type ?? "unknown"}): ${usd(e.amount_usd)}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const cx = inc.counterparty_exposure;
|
|
327
|
+
if (cx?.risk?.level || cx?.outflow?.length || cx?.inflow?.length) {
|
|
328
|
+
lines.push(``, `### Attacker Counterparty Exposure`);
|
|
329
|
+
if (cx.risk?.level) {
|
|
330
|
+
lines.push(`**Exposure Risk:** ${cx.risk.level.toUpperCase()} (${cx.risk.pct}%)`);
|
|
331
|
+
if (cx.risk.details?.length)
|
|
332
|
+
lines.push(...cx.risk.details.slice(0, 5).map((s) => `- ${s}`));
|
|
333
|
+
}
|
|
334
|
+
lines.push(...formatExposure("Outflow by category", cx.outflow));
|
|
335
|
+
lines.push(...formatExposure("Inflow by category", cx.inflow));
|
|
336
|
+
}
|
|
337
|
+
lines.push(``);
|
|
338
|
+
if (status === "traced") {
|
|
339
|
+
lines.push(`✅ Trace complete — full flow graph and counterparty exposure on ChainHint.`);
|
|
235
340
|
}
|
|
236
|
-
else if (
|
|
237
|
-
lines.push(
|
|
341
|
+
else if (status === "analyzing") {
|
|
342
|
+
lines.push(`⏳ Trace in progress...`);
|
|
238
343
|
}
|
|
239
344
|
else {
|
|
240
|
-
lines.push(
|
|
345
|
+
lines.push(`⚠️ Status: ${status}`);
|
|
241
346
|
}
|
|
242
|
-
lines.push(
|
|
347
|
+
lines.push(`🔗 View full trace: https://chainhint.com/incident/${inc.id}`);
|
|
243
348
|
lines.push(`*Powered by ChainHint — chainhint.com*`);
|
|
244
349
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
245
350
|
}
|