chainhint-mcp 1.1.0 → 1.2.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.
Files changed (2) hide show
  1. package/dist/index.js +77 -11
  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.
@@ -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,15 @@ 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 ?? [];
354
419
  if (endpoints.length) {
355
420
  const byType = new Map();
356
421
  for (const e of endpoints) {
357
- const t = e.type ?? "unknown";
422
+ const t = endpointBucket(e.entity_category, e.type, e.entity_name ?? e.entity);
358
423
  const b = byType.get(t) ?? { usd: 0, n: 0, entities: new Map() };
359
424
  b.usd += e.amount_usd ?? 0;
360
425
  b.n += 1;
@@ -364,7 +429,8 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
364
429
  byType.set(t, b);
365
430
  }
366
431
  const totalUsd = [...byType.values()].reduce((s, b) => s + b.usd, 0);
367
- lines.push(``, `### Where the funds went (${endpoints.length} endpoints, ${usd(totalUsd)} tracked)`);
432
+ // Σ endpoint amounts counts every hop's inflow (multi-hop), so it is larger than the loss — name the base.
433
+ lines.push(``, `### Where the funds went (${endpoints.length} endpoints, ${usd(totalUsd)} observed at endpoints — not the loss figure)`);
368
434
  for (const [t, b] of [...byType.entries()].sort((a, b) => b[1].usd - a[1].usd)) {
369
435
  const top = [...b.entities.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([n]) => n);
370
436
  const pct = totalUsd > 0 ? ` (${((b.usd / totalUsd) * 100).toFixed(1)}%)` : "";
@@ -378,7 +444,7 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
378
444
  lines.push(`**Largest endpoints:**`);
379
445
  for (const e of topEndpoints) {
380
446
  const name = e.entity_name ?? e.entity ?? "unattributed";
381
- lines.push(`- ${truncateAddr(e.address)} — ${name} (${e.type ?? "unknown"}): ${usd(e.amount_usd)}`);
447
+ lines.push(`- ${truncateAddr(e.address)} — ${name} (${endpointBucket(e.entity_category, e.type, e.entity_name ?? e.entity)}): ${usd(e.amount_usd)}`);
382
448
  }
383
449
  }
384
450
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chainhint-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "ChainHint MCP server — crypto risk intelligence tools for Claude Desktop and Cursor",
5
5
  "license": "MIT",
6
6
  "repository": {