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.
Files changed (2) hide show
  1. package/dist/index.js +85 -14
  2. 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
- 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)}`;
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, solana, bitcoin (default: auto-detect from address format)"),
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, solana, bitcoin, tron, ton)"),
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
- const lossUsd = inc.amount_usd ?? inc.estimated_loss_usd;
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 maxDepth = edges.reduce((m, e) => Math.max(m, e.depth ?? 0), 0);
414
+ const hops = hopCount(edges, inc.attacker_address);
350
415
  lines.push(``, `### Trace Graph`);
351
- lines.push(`**Hops traced:** ${maxDepth} · **Addresses:** ${nodes.length} · **Transfers:** ${edges.length}`);
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 ?? "unknown";
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
- lines.push(``, `### Where the funds went (${endpoints.length} endpoints, ${usd(totalUsd)} tracked)`);
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}**: ${usd(b.usd)}${pct} across ${b.n} address(es)${top.length ? ` — ${top.join(", ")}` : ""}`);
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 ?? "unknown"}): ${usd(e.amount_usd)}`);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chainhint-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "ChainHint MCP server — crypto risk intelligence tools for Claude Desktop and Cursor",
5
5
  "license": "MIT",
6
6
  "repository": {