chainhint-mcp 1.1.0 → 1.2.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/dist/index.js +85 -14
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -105,14 +105,78 @@ function formatRiskLevel(score) {
|
|
|
105
105
|
function truncateAddr(addr) {
|
|
106
106
|
return addr.length > 12 ? `${addr.slice(0, 8)}...${addr.slice(-6)}` : addr;
|
|
107
107
|
}
|
|
108
|
+
// Same tiers as the site's formatUsd (src/lib/utils.ts): $1.4B, $115.0M, $629.4K.
|
|
109
|
+
// KIR-45: this used to stop at "M" and print "$1409.97M" for the Bybit loss.
|
|
108
110
|
function usd(n) {
|
|
109
111
|
if (n == null || !Number.isFinite(n))
|
|
110
112
|
return "Unknown";
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
const abs = Math.abs(n);
|
|
114
|
+
if (abs >= 1e15)
|
|
115
|
+
return "Unknown";
|
|
116
|
+
if (abs >= 1e12)
|
|
117
|
+
return `$${(n / 1e12).toFixed(1)}T`;
|
|
118
|
+
if (abs >= 1e9)
|
|
119
|
+
return `$${(n / 1e9).toFixed(1)}B`;
|
|
120
|
+
if (abs >= 1e6)
|
|
121
|
+
return `$${(n / 1e6).toFixed(1)}M`;
|
|
122
|
+
if (abs >= 1e3)
|
|
123
|
+
return `$${(n / 1e3).toFixed(1)}K`;
|
|
124
|
+
return `$${n.toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
|
|
125
|
+
}
|
|
126
|
+
// Hop count as the site counts it (src/lib/traceReconciliation deriveDepthMap):
|
|
127
|
+
// shortest-path depth from the attacker over the edges, max over reachable
|
|
128
|
+
// nodes — NOT max(edge.depth), which the tracer assigns as it walks (Bybit:
|
|
129
|
+
// 6 vs the site's 5).
|
|
130
|
+
function hopCount(edges, attacker) {
|
|
131
|
+
const src = (attacker ?? "").toLowerCase();
|
|
132
|
+
if (!src || !edges.length)
|
|
133
|
+
return 0;
|
|
134
|
+
const out = new Map();
|
|
135
|
+
for (const e of edges) {
|
|
136
|
+
const f = (e.from ?? "").toLowerCase(), t = (e.to ?? "").toLowerCase();
|
|
137
|
+
if (!f || !t)
|
|
138
|
+
continue;
|
|
139
|
+
(out.get(f) ?? out.set(f, []).get(f)).push(t);
|
|
140
|
+
}
|
|
141
|
+
const depth = new Map([[src, 0]]);
|
|
142
|
+
const queue = [src];
|
|
143
|
+
let max = 0;
|
|
144
|
+
while (queue.length) {
|
|
145
|
+
const a = queue.shift();
|
|
146
|
+
const d = depth.get(a);
|
|
147
|
+
for (const b of out.get(a) ?? []) {
|
|
148
|
+
if (depth.has(b))
|
|
149
|
+
continue;
|
|
150
|
+
depth.set(b, d + 1);
|
|
151
|
+
if (d + 1 > max)
|
|
152
|
+
max = d + 1;
|
|
153
|
+
queue.push(b);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return max;
|
|
157
|
+
}
|
|
158
|
+
// Endpoint buckets as the site's Flow Summary (src/lib/flowSummary.ts): the
|
|
159
|
+
// entity CATEGORY decides — an attacker wallet retyped "exchange" is not an
|
|
160
|
+
// exchange, a no-KYC swap is not a freeze target.
|
|
161
|
+
const ATTACKER_CATEGORIES = new Set(["hacker", "scam", "exploit"]);
|
|
162
|
+
const NO_KYC_TOKENS = ["changenow", "fixedfloat", "simpleswap", "sideshift", "stealthex", "changelly", "letsexchange", "godex", "swapuz", "exolix", "exch.cx", "exch.sc", "exch.net", "ff.io"];
|
|
163
|
+
function endpointBucket(category, type, name) {
|
|
164
|
+
const c = (category ?? "").toLowerCase();
|
|
165
|
+
const t = (type ?? "").toLowerCase();
|
|
166
|
+
const n = (name ?? "").toLowerCase().replace(/[\s_-]/g, "");
|
|
167
|
+
if (ATTACKER_CATEGORIES.has(c))
|
|
168
|
+
return "attacker-attributed";
|
|
169
|
+
if (c === "sanctioned" || c === "high_risk_exchange" || NO_KYC_TOKENS.some((k) => n.includes(k.replace(/[\s_-]/g, ""))))
|
|
170
|
+
return "risky";
|
|
171
|
+
if (c === "mixer" || t === "mixer")
|
|
172
|
+
return "mixer";
|
|
173
|
+
if (c === "bridge" || t === "bridge")
|
|
174
|
+
return "bridge";
|
|
175
|
+
if (c === "exchange" || t === "exchange")
|
|
176
|
+
return "exchange";
|
|
177
|
+
if (c === "defi" || c === "dex" || t === "defi" || t === "dex")
|
|
178
|
+
return "defi";
|
|
179
|
+
return "unknown";
|
|
116
180
|
}
|
|
117
181
|
// EVM addresses are case-insensitive and stored lowercase; base58 chains
|
|
118
182
|
// (BTC/TRON/SOL/TON) are case-sensitive — never lowercase those.
|
|
@@ -154,7 +218,7 @@ const server = new McpServer({
|
|
|
154
218
|
// ── Tool 1: check_wallet_risk ─────────────────────────────────────────────────
|
|
155
219
|
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.", {
|
|
156
220
|
address: z.string().describe("Wallet address to check (EVM 0x..., Bitcoin, or Solana)"),
|
|
157
|
-
chain: z.string().optional().describe("Blockchain: ethereum, bsc, polygon, arbitrum, optimism, base, avalanche,
|
|
221
|
+
chain: z.string().optional().describe("Blockchain: ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, gnosis, bitcoin, solana, tron, ton (default: auto-detect from address format; the response chain is the address family for bitcoin/solana/tron/ton)"),
|
|
158
222
|
}, async ({ address, chain }) => {
|
|
159
223
|
try {
|
|
160
224
|
const params = { address };
|
|
@@ -217,7 +281,7 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
|
|
|
217
281
|
// ── Tool 2: lookup_address ────────────────────────────────────────────────────
|
|
218
282
|
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.", {
|
|
219
283
|
address: z.string().describe("Blockchain address to look up"),
|
|
220
|
-
chain: z.string().optional().describe("Blockchain (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche,
|
|
284
|
+
chain: z.string().optional().describe("Blockchain (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, gnosis, bitcoin, solana, tron, ton)"),
|
|
221
285
|
}, async ({ address, chain }) => {
|
|
222
286
|
try {
|
|
223
287
|
const params = { address };
|
|
@@ -323,7 +387,8 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
|
|
|
323
387
|
};
|
|
324
388
|
}
|
|
325
389
|
const inc = rows[0];
|
|
326
|
-
|
|
390
|
+
// The site's figure: stored estimated_loss_usd first (TraceFacts.displayedLossUsd), amount_usd as fallback.
|
|
391
|
+
const lossUsd = inc.estimated_loss_usd ?? inc.amount_usd;
|
|
327
392
|
const date = inc.display_date ?? inc.hack_date ?? inc.created_at;
|
|
328
393
|
const status = (inc.status ?? "unknown").toLowerCase();
|
|
329
394
|
const lines = [
|
|
@@ -346,15 +411,20 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
|
|
|
346
411
|
const edges = inc.flow_graph?.edges ?? [];
|
|
347
412
|
const nodes = inc.flow_graph?.nodes ?? [];
|
|
348
413
|
if (edges.length) {
|
|
349
|
-
const
|
|
414
|
+
const hops = hopCount(edges, inc.attacker_address);
|
|
350
415
|
lines.push(``, `### Trace Graph`);
|
|
351
|
-
lines.push(`**Hops traced:** ${
|
|
416
|
+
lines.push(`**Hops traced:** ${hops} · **Addresses:** ${nodes.length} · **Transfers:** ${edges.length}`);
|
|
352
417
|
}
|
|
353
418
|
const endpoints = inc.endpoints ?? [];
|
|
419
|
+
// A graph none of whose edges carries amount_usd was never valued (legacy
|
|
420
|
+
// Bitcoin rows): endpoint.amount_usd then holds the raw asset quantity and
|
|
421
|
+
// must not be printed as dollars (multi-chain audit 2026-09-03).
|
|
422
|
+
const graphPriced = edges.length === 0 || edges.some((e) => e.amount_usd != null && Number(e.amount_usd) > 0);
|
|
423
|
+
const money = (n) => (graphPriced ? usd(n) : "unpriced");
|
|
354
424
|
if (endpoints.length) {
|
|
355
425
|
const byType = new Map();
|
|
356
426
|
for (const e of endpoints) {
|
|
357
|
-
const t = e.type ??
|
|
427
|
+
const t = endpointBucket(e.entity_category, e.type, e.entity_name ?? e.entity);
|
|
358
428
|
const b = byType.get(t) ?? { usd: 0, n: 0, entities: new Map() };
|
|
359
429
|
b.usd += e.amount_usd ?? 0;
|
|
360
430
|
b.n += 1;
|
|
@@ -364,11 +434,12 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
|
|
|
364
434
|
byType.set(t, b);
|
|
365
435
|
}
|
|
366
436
|
const totalUsd = [...byType.values()].reduce((s, b) => s + b.usd, 0);
|
|
367
|
-
|
|
437
|
+
// Σ endpoint amounts counts every hop's inflow (multi-hop), so it is larger than the loss — name the base.
|
|
438
|
+
lines.push(``, `### Where the funds went (${endpoints.length} endpoints, ${graphPriced ? `${usd(totalUsd)} observed at endpoints — not the loss figure` : "this trace carries no USD valuation"})`);
|
|
368
439
|
for (const [t, b] of [...byType.entries()].sort((a, b) => b[1].usd - a[1].usd)) {
|
|
369
440
|
const top = [...b.entities.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([n]) => n);
|
|
370
441
|
const pct = totalUsd > 0 ? ` (${((b.usd / totalUsd) * 100).toFixed(1)}%)` : "";
|
|
371
|
-
lines.push(`- **${t}**: ${
|
|
442
|
+
lines.push(`- **${t}**: ${money(b.usd)}${graphPriced ? pct : ""} across ${b.n} address(es)${top.length ? ` — ${top.join(", ")}` : ""}`);
|
|
372
443
|
}
|
|
373
444
|
const topEndpoints = [...endpoints]
|
|
374
445
|
.filter((e) => (e.amount_usd ?? 0) > 0)
|
|
@@ -378,7 +449,7 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
|
|
|
378
449
|
lines.push(`**Largest endpoints:**`);
|
|
379
450
|
for (const e of topEndpoints) {
|
|
380
451
|
const name = e.entity_name ?? e.entity ?? "unattributed";
|
|
381
|
-
lines.push(`- ${truncateAddr(e.address)} — ${name} (${e.type ??
|
|
452
|
+
lines.push(`- ${truncateAddr(e.address)} — ${name} (${endpointBucket(e.entity_category, e.type, e.entity_name ?? e.entity)}): ${money(e.amount_usd)}`);
|
|
382
453
|
}
|
|
383
454
|
}
|
|
384
455
|
}
|