chainhint-mcp 1.0.1 → 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.
- package/README.md +36 -15
- package/dist/index.d.ts +3 -2
- package/dist/index.js +157 -32
- package/package.json +1 -1
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` | Fast risk score, entity, labels, sanctions hit (54M+ labeled addresses)
|
|
10
|
-
| `lookup_address` | Deep address report — entity, risk factors, GoPlus flags, counterparty exposure, balance
|
|
11
|
-
| `get_trace_status` | Fund-trace summary for a public hack incident — hops, endpoints by type (exchange/mixer/bridge/defi), exposure
|
|
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 incident — hops, endpoints by type (exchange/mixer/bridge/defi), exposure | Unlimited |
|
|
12
12
|
|
|
13
13
|
## Requirements
|
|
14
14
|
|
|
15
15
|
- Node.js 18+
|
|
16
|
-
-
|
|
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,7 +96,7 @@ npm run dev
|
|
|
75
96
|
|
|
76
97
|
| Variable | Required | Description |
|
|
77
98
|
|----------|----------|-------------|
|
|
78
|
-
| `CHAINHINT_API_KEY` | — |
|
|
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
101
|
| `CHAINHINT_SUPABASE_ANON_KEY` | — | Override anon key for get_trace_status (public incidents) |
|
|
81
102
|
|
package/dist/index.d.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* - lookup_address → address-lookup API (entity, risk factors, exposure, balance) — public
|
|
11
11
|
* - get_trace_status → public incident fund-trace summary (endpoints, hops, exposure) — public
|
|
12
12
|
*
|
|
13
|
-
* Auth:
|
|
14
|
-
*
|
|
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.
|
|
15
16
|
*/
|
|
16
17
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* - lookup_address → address-lookup API (entity, risk factors, exposure, balance) — public
|
|
11
11
|
* - get_trace_status → public incident fund-trace summary (endpoints, hops, exposure) — public
|
|
12
12
|
*
|
|
13
|
-
* Auth:
|
|
14
|
-
*
|
|
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.
|
|
15
16
|
*/
|
|
16
17
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
18
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -21,30 +22,55 @@ const API_KEY = process.env.CHAINHINT_API_KEY;
|
|
|
21
22
|
const BASE_URL = process.env.CHAINHINT_API_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co/functions/v1";
|
|
22
23
|
const SUPABASE_URL = process.env.CHAINHINT_SUPABASE_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co";
|
|
23
24
|
const SUPABASE_ANON_KEY = process.env.CHAINHINT_SUPABASE_ANON_KEY ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtqaXdmd3ltbnV6eHJpb2toY2prIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI3MzkxODgsImV4cCI6MjA4ODMxNTE4OH0.VqzzF_jI8zF072cbjWEDbYo3PnMDlIPy621iWkXEqyo";
|
|
24
|
-
const
|
|
25
|
-
|
|
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";
|
|
26
29
|
if (!API_KEY) {
|
|
27
|
-
console.error(
|
|
28
|
-
console.error(" lookup_address and get_trace_status work without a key.");
|
|
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).`);
|
|
29
31
|
}
|
|
30
|
-
|
|
31
|
-
async function apiGet(path, params, opts) {
|
|
32
|
+
async function apiGet(path, params) {
|
|
32
33
|
const url = new URL(`${BASE_URL}${path}`);
|
|
33
34
|
for (const [k, v] of Object.entries(params)) {
|
|
34
35
|
if (v)
|
|
35
36
|
url.searchParams.set(k, v);
|
|
36
37
|
}
|
|
37
|
-
|
|
38
|
-
|
|
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) {
|
|
39
46
|
headers["X-Api-Key"] = API_KEY;
|
|
40
47
|
headers["Authorization"] = `Bearer ${API_KEY}`;
|
|
41
48
|
}
|
|
42
49
|
const res = await fetch(url.toString(), { headers });
|
|
43
50
|
const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` })));
|
|
44
51
|
if (!res.ok) {
|
|
45
|
-
|
|
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);
|
|
46
57
|
}
|
|
47
|
-
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}.`;
|
|
48
74
|
}
|
|
49
75
|
async function supabaseGet(table, params) {
|
|
50
76
|
const url = new URL(`${SUPABASE_URL}/rest/v1/${table}`);
|
|
@@ -79,14 +105,78 @@ 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
|
+
// 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.
|
|
82
110
|
function usd(n) {
|
|
83
111
|
if (n == null || !Number.isFinite(n))
|
|
84
112
|
return "Unknown";
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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";
|
|
90
180
|
}
|
|
91
181
|
// EVM addresses are case-insensitive and stored lowercase; base58 chains
|
|
92
182
|
// (BTC/TRON/SOL/TON) are case-sensitive — never lowercase those.
|
|
@@ -106,31 +196,58 @@ function formatExposure(label, buckets) {
|
|
|
106
196
|
}),
|
|
107
197
|
];
|
|
108
198
|
}
|
|
199
|
+
/** Public incident whose attacker_address matches, or null (errors swallowed — best effort). */
|
|
200
|
+
async function findPublicIncidentByAttacker(address) {
|
|
201
|
+
try {
|
|
202
|
+
const rows = await supabaseGet("public_incidents_view", {
|
|
203
|
+
select: "id,title,chain,amount_usd,estimated_loss_usd,risk_score",
|
|
204
|
+
attacker_address: `eq.${normalizeAddr(address)}`,
|
|
205
|
+
limit: "1",
|
|
206
|
+
});
|
|
207
|
+
return rows[0] ?? null;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
109
213
|
// ── MCP Server ────────────────────────────────────────────────────────────────
|
|
110
214
|
const server = new McpServer({
|
|
111
215
|
name: "chainhint",
|
|
112
|
-
version:
|
|
216
|
+
version: VERSION,
|
|
113
217
|
});
|
|
114
218
|
// ── Tool 1: 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.
|
|
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.", {
|
|
116
220
|
address: z.string().describe("Wallet address to check (EVM 0x..., Bitcoin, or Solana)"),
|
|
117
221
|
chain: z.string().optional().describe("Blockchain: ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, solana, bitcoin (default: auto-detect from address format)"),
|
|
118
222
|
}, async ({ address, chain }) => {
|
|
119
|
-
if (!API_KEY) {
|
|
120
|
-
return { content: [{ type: "text", text: NO_KEY_MESSAGE }] };
|
|
121
|
-
}
|
|
122
223
|
try {
|
|
123
224
|
const params = { address };
|
|
124
225
|
if (chain)
|
|
125
226
|
params.chain = chain;
|
|
126
227
|
// wallet-reputation returns a flat object (no {success,data} wrapper).
|
|
127
|
-
const d = await apiGet("/wallet-reputation", params
|
|
228
|
+
const { body: d, headers } = await apiGet("/wallet-reputation", params);
|
|
128
229
|
const lines = [
|
|
129
230
|
`## Wallet Risk Report: ${truncateAddr(d.address)}`,
|
|
130
231
|
`**Address:** ${d.address}`,
|
|
131
232
|
`**Chain:** ${d.chain}`,
|
|
132
|
-
`**Risk Score:** ${d.risk_score}/100 — **${(d.risk_level ?? formatRiskLevel(d.risk_score)).toUpperCase()}**`,
|
|
133
233
|
];
|
|
234
|
+
// Canon (same as chainhint.com and the TG bot): an address that is not in
|
|
235
|
+
// the labeled database has NO DATA — that is not evidence it is clean.
|
|
236
|
+
// The API still returns risk_score 0 / "clean" for not-found, so the
|
|
237
|
+
// wording is fixed here, and public incidents are cross-checked so a
|
|
238
|
+
// known hack attacker that never got an `addresses` row is not
|
|
239
|
+
// presented as unknown.
|
|
240
|
+
if (!d.found_in_db) {
|
|
241
|
+
lines.push(`**Risk:** ⚪ NO DATA — address is not in ChainHint's labeled database. This is not evidence it is clean.`);
|
|
242
|
+
const inc = await findPublicIncidentByAttacker(d.address);
|
|
243
|
+
if (inc) {
|
|
244
|
+
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}`);
|
|
245
|
+
}
|
|
246
|
+
lines.push(`Use lookup_address for an on-chain assessment (risk factors, GoPlus flags, counterparty exposure).`);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
lines.push(`**Risk Score:** ${d.risk_score}/100 — **${(d.risk_level ?? formatRiskLevel(d.risk_score)).toUpperCase()}**`);
|
|
250
|
+
}
|
|
134
251
|
if (d.sanctions?.hit) {
|
|
135
252
|
lines.push(`⛔ **SANCTIONED / OFAC-linked**`);
|
|
136
253
|
}
|
|
@@ -151,6 +268,9 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
|
|
|
151
268
|
lines.push(`**Type:** ${d.is_contract ? "Smart Contract" : "EOA (wallet)"}`);
|
|
152
269
|
lines.push(`**In ChainHint DB:** ${d.found_in_db ? "yes" : "no"}${d.sources?.length ? ` (sources: ${d.sources.join(", ")})` : ""}`);
|
|
153
270
|
lines.push(`**Checked at:** ${d.checked_at}`);
|
|
271
|
+
const quota = quotaLine(headers, "checks");
|
|
272
|
+
if (quota)
|
|
273
|
+
lines.push(``, quota);
|
|
154
274
|
lines.push(`\n*Powered by ChainHint — chainhint.com*`);
|
|
155
275
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
156
276
|
}
|
|
@@ -159,7 +279,7 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
|
|
|
159
279
|
}
|
|
160
280
|
});
|
|
161
281
|
// ── Tool 2: 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.
|
|
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.", {
|
|
163
283
|
address: z.string().describe("Blockchain address to look up"),
|
|
164
284
|
chain: z.string().optional().describe("Blockchain (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, solana, bitcoin, tron, ton)"),
|
|
165
285
|
}, async ({ address, chain }) => {
|
|
@@ -167,7 +287,7 @@ server.tool("lookup_address", "Detailed lookup of a blockchain address: entity a
|
|
|
167
287
|
const params = { address };
|
|
168
288
|
if (chain)
|
|
169
289
|
params.chain = chain;
|
|
170
|
-
const data = await apiGet("/address-lookup", params
|
|
290
|
+
const { body: data, headers: lookupHeaders } = await apiGet("/address-lookup", params);
|
|
171
291
|
if (!data.success || !data.data) {
|
|
172
292
|
return { content: [{ type: "text", text: `Error: ${data.error ?? "Unknown error"}` }] };
|
|
173
293
|
}
|
|
@@ -223,6 +343,9 @@ server.tool("lookup_address", "Detailed lookup of a blockchain address: entity a
|
|
|
223
343
|
lines.push(...formatExposure("Inflow by category", ex.inflow));
|
|
224
344
|
lines.push(...formatExposure("Outflow by category", ex.outflow));
|
|
225
345
|
}
|
|
346
|
+
const lookupQuota = quotaLine(lookupHeaders, "lookups");
|
|
347
|
+
if (lookupQuota)
|
|
348
|
+
lines.push(``, lookupQuota);
|
|
226
349
|
lines.push(``, `🔗 https://chainhint.com/address/${d.address}?chain=${d.chain}`);
|
|
227
350
|
lines.push(`*Powered by ChainHint — chainhint.com*`);
|
|
228
351
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
@@ -264,7 +387,8 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
|
|
|
264
387
|
};
|
|
265
388
|
}
|
|
266
389
|
const inc = rows[0];
|
|
267
|
-
|
|
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;
|
|
268
392
|
const date = inc.display_date ?? inc.hack_date ?? inc.created_at;
|
|
269
393
|
const status = (inc.status ?? "unknown").toLowerCase();
|
|
270
394
|
const lines = [
|
|
@@ -287,15 +411,15 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
|
|
|
287
411
|
const edges = inc.flow_graph?.edges ?? [];
|
|
288
412
|
const nodes = inc.flow_graph?.nodes ?? [];
|
|
289
413
|
if (edges.length) {
|
|
290
|
-
const
|
|
414
|
+
const hops = hopCount(edges, inc.attacker_address);
|
|
291
415
|
lines.push(``, `### Trace Graph`);
|
|
292
|
-
lines.push(`**Hops traced:** ${
|
|
416
|
+
lines.push(`**Hops traced:** ${hops} · **Addresses:** ${nodes.length} · **Transfers:** ${edges.length}`);
|
|
293
417
|
}
|
|
294
418
|
const endpoints = inc.endpoints ?? [];
|
|
295
419
|
if (endpoints.length) {
|
|
296
420
|
const byType = new Map();
|
|
297
421
|
for (const e of endpoints) {
|
|
298
|
-
const t = e.type ??
|
|
422
|
+
const t = endpointBucket(e.entity_category, e.type, e.entity_name ?? e.entity);
|
|
299
423
|
const b = byType.get(t) ?? { usd: 0, n: 0, entities: new Map() };
|
|
300
424
|
b.usd += e.amount_usd ?? 0;
|
|
301
425
|
b.n += 1;
|
|
@@ -305,7 +429,8 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
|
|
|
305
429
|
byType.set(t, b);
|
|
306
430
|
}
|
|
307
431
|
const totalUsd = [...byType.values()].reduce((s, b) => s + b.usd, 0);
|
|
308
|
-
|
|
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)`);
|
|
309
434
|
for (const [t, b] of [...byType.entries()].sort((a, b) => b[1].usd - a[1].usd)) {
|
|
310
435
|
const top = [...b.entities.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([n]) => n);
|
|
311
436
|
const pct = totalUsd > 0 ? ` (${((b.usd / totalUsd) * 100).toFixed(1)}%)` : "";
|
|
@@ -319,7 +444,7 @@ server.tool("get_trace_status", "Fund-trace summary for a publicly tracked crypt
|
|
|
319
444
|
lines.push(`**Largest endpoints:**`);
|
|
320
445
|
for (const e of topEndpoints) {
|
|
321
446
|
const name = e.entity_name ?? e.entity ?? "unattributed";
|
|
322
|
-
lines.push(`- ${truncateAddr(e.address)} — ${name} (${e.type ??
|
|
447
|
+
lines.push(`- ${truncateAddr(e.address)} — ${name} (${endpointBucket(e.entity_category, e.type, e.entity_name ?? e.entity)}): ${usd(e.amount_usd)}`);
|
|
323
448
|
}
|
|
324
449
|
}
|
|
325
450
|
}
|