chainhint-mcp 1.3.3 → 1.3.4

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 CHANGED
@@ -6,7 +6,7 @@ Crypto risk intelligence for Claude Desktop, Cursor, and any MCP-compatible AI.
6
6
 
7
7
  | Tool | Description | Limit |
8
8
  |------|-------------|-------|
9
- | `check_wallet_risk` | Fast risk score, entity, labels, sanctions hit (54M+ labeled addresses) | **3 / day** free · 10,000 / day with key |
9
+ | `check_wallet_risk` | Fast risk score, entity, labels, sanctions hit — 45M+ attributed addresses across 12 chains, including 485K+ linked to illicit activity and 1,000+ sanctioned addresses across 5 authorities (OFAC, EU, AU, JP, FR) | **3 / day** free · 10,000 / day with key |
10
10
  | `lookup_address` | Deep address report — entity, risk factors, GoPlus flags, counterparty exposure, balance | **10 / day** free · 100 / hour with key |
11
11
  | `get_trace_status` | Fund-trace summary for a public hack incident — hops, endpoints by type (exchange/mixer/bridge/defi), exposure | Unlimited |
12
12
 
@@ -88,6 +88,14 @@ Free tier: 2 of 3 checks left today — set CHAINHINT_API_KEY (Agency plan, http
88
88
 
89
89
  When the address is in ChainHint's agent-infrastructure registry (agent-token launchpads, deployer factories, routers, payment facilitators, known agent wallets), the report adds an `🤖` line, e.g. *Known agent infrastructure: Virtuals launchpad* — a registry fact you can verify at its source, not a behavioural classification and not part of the risk score.
90
90
 
91
+ Evidence and entity lines say what the finding is, never more:
92
+
93
+ - `⛔ Sanctioned` only for a sanctions designation; an issuer freeze, a law-enforcement attribution, a seizure order or a blocklist each says *Not a sanctions designation.*
94
+ - A freeze of the tokens held by a token's own contract (Tether blacklisted the USDT held by the USDT contract) prints *Tether blacklisted tokens held by this contract* — it carries no risk weight and is not a "Frozen by issuer" verdict.
95
+ - An entity whose attribution is unconfirmed is printed as a candidate (*⚠️ Attribution unconfirmed*) and a venue that no longer operates is marked *Venue closed* or *Bankrupt*.
96
+
97
+ Addresses: EVM `0x…`, Bitcoin, Solana, TRON and TON — for TON any mainnet form works (friendly `EQ…`/`UQ…`/`Ef…`/`Uf…` or raw `0:<hex>`); testnet `kQ…`/`0Q…` is not covered.
98
+
91
99
  ## Development (no build step)
92
100
 
93
101
  ```bash
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Address input handling — pure functions, tested in test/address.test.mjs.
3
+ *
4
+ * The MCP does not validate addresses: the ChainHint API does (400 with a
5
+ * format hint) and resolves every spelling of an address to its stored form
6
+ * (main repo: backend/supabase/functions/_shared/address-canon.ts). The tools
7
+ * only trim and lower-case EVM hex. Anything else is passed exactly as typed:
8
+ * base58 chains (BTC/TRON/SOL) are case-sensitive, and TON friendly addresses
9
+ * (EQ…/UQ…/Ef…/Uf…) carry a checksum over their case.
10
+ */
11
+ /** EVM → lower case; every other chain's address is kept as typed (trimmed). */
12
+ export declare function normalizeAddr(addr: string): string;
13
+ /**
14
+ * Shape of a TON address, no checksum: "raw" (0:/-1: + 64 hex), "friendly"
15
+ * (48-char mainnet EQ/UQ/Ef/Uf), "testnet" (kQ/0Q/kf/0f — ChainHint does not
16
+ * cover testnet), or null. Informational only; the API decides validity.
17
+ */
18
+ export declare function tonAddressForm(addr: string): "raw" | "friendly" | "testnet" | null;
19
+ /** Tool parameter text: which address spellings the tools accept. */
20
+ export declare const ADDRESS_FORMS = "EVM 0x\u2026, Bitcoin (1\u2026/3\u2026/bc1\u2026), Solana, TRON (T\u2026), or TON \u2014 mainnet friendly EQ\u2026/UQ\u2026/Ef\u2026/Uf\u2026 or raw 0:<hex>/-1:<hex>, any form finds the same address (testnet kQ\u2026/0Q\u2026 is not covered)";
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Address input handling — pure functions, tested in test/address.test.mjs.
3
+ *
4
+ * The MCP does not validate addresses: the ChainHint API does (400 with a
5
+ * format hint) and resolves every spelling of an address to its stored form
6
+ * (main repo: backend/supabase/functions/_shared/address-canon.ts). The tools
7
+ * only trim and lower-case EVM hex. Anything else is passed exactly as typed:
8
+ * base58 chains (BTC/TRON/SOL) are case-sensitive, and TON friendly addresses
9
+ * (EQ…/UQ…/Ef…/Uf…) carry a checksum over their case.
10
+ */
11
+ /** EVM → lower case; every other chain's address is kept as typed (trimmed). */
12
+ export function normalizeAddr(addr) {
13
+ const a = addr.trim();
14
+ return /^0x[0-9a-fA-F]{40}$/.test(a) ? a.toLowerCase() : a;
15
+ }
16
+ /**
17
+ * Shape of a TON address, no checksum: "raw" (0:/-1: + 64 hex), "friendly"
18
+ * (48-char mainnet EQ/UQ/Ef/Uf), "testnet" (kQ/0Q/kf/0f — ChainHint does not
19
+ * cover testnet), or null. Informational only; the API decides validity.
20
+ */
21
+ export function tonAddressForm(addr) {
22
+ const a = addr.trim();
23
+ if (/^(0|-1):[0-9a-fA-F]{64}$/.test(a))
24
+ return "raw";
25
+ if (!/^[A-Za-z0-9_+/-]{48}$/.test(a))
26
+ return null;
27
+ if (/^(EQ|UQ|Ef|Uf)/.test(a))
28
+ return "friendly";
29
+ if (/^(kQ|0Q|kf|0f)/.test(a))
30
+ return "testnet";
31
+ return null;
32
+ }
33
+ /** Tool parameter text: which address spellings the tools accept. */
34
+ export const ADDRESS_FORMS = "EVM 0x…, Bitcoin (1…/3…/bc1…), Solana, TRON (T…), or TON — mainnet friendly EQ…/UQ…/Ef…/Uf… or raw 0:<hex>/-1:<hex>, any form finds the same address (testnet kQ…/0Q… is not covered)";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Public database figures — the ChainHint canon (owner decision 2026-09-15,
3
+ * main repo migration 143, public/llms.txt). test/canon.test.mjs pins it and
4
+ * forbids the retired address / entity counts in the source and the README.
5
+ * Change only together with the site.
6
+ */
7
+ export declare const ATTRIBUTED_ADDRESSES = "45M+";
8
+ export declare const ILLICIT_ADDRESSES = "485K+";
9
+ export declare const SANCTIONED_ADDRESSES = "1,000+";
10
+ export declare const SANCTIONS_AUTHORITIES = "OFAC, EU, AU, JP, FR";
11
+ /** "45M+ attributed addresses across 12 chains, including 485K+ …" */
12
+ export declare const DATABASE_CANON = "45M+ attributed addresses across 12 chains, including 485K+ addresses linked to illicit activity and 1,000+ sanctioned addresses across 5 authorities (OFAC, EU, AU, JP, FR)";
package/dist/canon.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Public database figures — the ChainHint canon (owner decision 2026-09-15,
3
+ * main repo migration 143, public/llms.txt). test/canon.test.mjs pins it and
4
+ * forbids the retired address / entity counts in the source and the README.
5
+ * Change only together with the site.
6
+ */
7
+ export const ATTRIBUTED_ADDRESSES = "45M+";
8
+ export const ILLICIT_ADDRESSES = "485K+";
9
+ export const SANCTIONED_ADDRESSES = "1,000+";
10
+ export const SANCTIONS_AUTHORITIES = "OFAC, EU, AU, JP, FR";
11
+ /** "45M+ attributed addresses across 12 chains, including 485K+ …" */
12
+ export const DATABASE_CANON = `${ATTRIBUTED_ADDRESSES} attributed addresses across 12 chains, including ${ILLICIT_ADDRESSES} addresses linked to illicit activity and ${SANCTIONED_ADDRESSES} sanctioned addresses across 5 authorities (${SANCTIONS_AUTHORITIES})`;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Entity wording — pure functions, tested in test/entity.test.mjs.
3
+ *
4
+ * Mirrors two optional entity fields the ChainHint API adds with migration 144
5
+ * (main repo: backend/supabase/functions/_shared/entity-attribution.ts,
6
+ * src/lib/entityCategory.ts):
7
+ *
8
+ * attribution_unconfirmed: true — the named actor is a CANDIDATE, not a
9
+ * finding (e.g. Flipside-sourced Coinbase forwarders). wallet-reputation
10
+ * then answers top-level `category: "unknown"`. Shown, marked, never acted
11
+ * on: not a known entity, not a freeze target, not a reason to lower risk.
12
+ * operating_status: "closed" | "bankrupt" — the venue no longer operates.
13
+ *
14
+ * Both are optional: an API response without them prints exactly what 1.3.3
15
+ * printed.
16
+ */
17
+ export interface EntityBlock {
18
+ name: string;
19
+ category?: string | null;
20
+ subcategory?: string | null;
21
+ verified?: boolean | null;
22
+ /** 0..1 (address-lookup). */
23
+ confidence?: number | null;
24
+ attribution_unconfirmed?: boolean | null;
25
+ operating_status?: string | null;
26
+ }
27
+ export type OperatingStatus = "closed" | "bankrupt";
28
+ export declare function isUnconfirmedAttribution(e: EntityBlock | null | undefined): boolean;
29
+ /** "closed" | "bankrupt" when set; unknown values are ignored (same as the backend). */
30
+ export declare function entityOperatingStatus(e: EntityBlock | null | undefined): OperatingStatus | null;
31
+ /** The sentences printed after the entity, in order. Empty when there is nothing to say. */
32
+ export declare function entityMarkers(e: EntityBlock | null | undefined): string[];
33
+ /**
34
+ * "**Entity:** Binance (exchange / hot_wallet, verified)" — plus the markers.
35
+ * An unconfirmed attribution prints its stored category as a candidate and
36
+ * never as verified.
37
+ */
38
+ export declare function entityLine(e: EntityBlock, opts?: {
39
+ showVerified?: boolean;
40
+ }): string;
package/dist/entity.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Entity wording — pure functions, tested in test/entity.test.mjs.
3
+ *
4
+ * Mirrors two optional entity fields the ChainHint API adds with migration 144
5
+ * (main repo: backend/supabase/functions/_shared/entity-attribution.ts,
6
+ * src/lib/entityCategory.ts):
7
+ *
8
+ * attribution_unconfirmed: true — the named actor is a CANDIDATE, not a
9
+ * finding (e.g. Flipside-sourced Coinbase forwarders). wallet-reputation
10
+ * then answers top-level `category: "unknown"`. Shown, marked, never acted
11
+ * on: not a known entity, not a freeze target, not a reason to lower risk.
12
+ * operating_status: "closed" | "bankrupt" — the venue no longer operates.
13
+ *
14
+ * Both are optional: an API response without them prints exactly what 1.3.3
15
+ * printed.
16
+ */
17
+ export function isUnconfirmedAttribution(e) {
18
+ return e?.attribution_unconfirmed === true;
19
+ }
20
+ /** "closed" | "bankrupt" when set; unknown values are ignored (same as the backend). */
21
+ export function entityOperatingStatus(e) {
22
+ const s = (e?.operating_status ?? "").toLowerCase();
23
+ return s === "closed" || s === "bankrupt" ? s : null;
24
+ }
25
+ /** The sentences printed after the entity, in order. Empty when there is nothing to say. */
26
+ export function entityMarkers(e) {
27
+ const out = [];
28
+ if (isUnconfirmedAttribution(e)) {
29
+ out.push("⚠️ Attribution unconfirmed — the named actor is a candidate, not a finding. Do not treat it as a known entity, a freeze target or a reason to lower risk.");
30
+ }
31
+ const status = entityOperatingStatus(e);
32
+ if (status === "closed")
33
+ out.push("Venue closed — a freeze request may not reach a compliance desk.");
34
+ if (status === "bankrupt")
35
+ out.push("Bankrupt — address the estate / trustee.");
36
+ return out;
37
+ }
38
+ /**
39
+ * "**Entity:** Binance (exchange / hot_wallet, verified)" — plus the markers.
40
+ * An unconfirmed attribution prints its stored category as a candidate and
41
+ * never as verified.
42
+ */
43
+ export function entityLine(e, opts = {}) {
44
+ const unconfirmed = isUnconfirmedAttribution(e);
45
+ const cat = [e.category, e.subcategory].filter((p) => !!p && p.trim() !== "").join(" / ");
46
+ const parts = [];
47
+ if (cat)
48
+ parts.push(unconfirmed ? `candidate: ${cat}` : cat);
49
+ if (!unconfirmed && opts.showVerified && e.verified)
50
+ parts.push("verified");
51
+ if (!unconfirmed && e.confidence != null && Number.isFinite(e.confidence)) {
52
+ parts.push(`confidence ${Math.round(e.confidence * 100)}%`);
53
+ }
54
+ const head = `**Entity:** ${e.name}${parts.length ? ` (${parts.join(", ")})` : ""}`;
55
+ const markers = entityMarkers(e);
56
+ return markers.length ? `${head} — ${markers.join(" ")}` : head;
57
+ }
@@ -21,9 +21,27 @@ export interface EvidenceItem {
21
21
  token?: string | null;
22
22
  chain?: string | null;
23
23
  first_seen_at?: string | null;
24
+ /**
25
+ * API since 2026-09-15: true only on an issuer freeze whose freezing token
26
+ * contract IS the looked-up address (Tether blacklisted the USDT held by the
27
+ * USDT contract). Scored 0 with no floor; printed as the backend's risk
28
+ * detail line, never as "Frozen by issuer".
29
+ */
30
+ held_by_token_contract?: boolean;
24
31
  }
25
32
  export declare const NOT_A_SANCTIONS_DESIGNATION = "Not a sanctions designation.";
33
+ /** True for the one freeze that says nothing about the address as a counterparty. */
34
+ export declare function isOwnTokenContractFreeze(e: Pick<EvidenceItem, "class" | "held_by_token_contract">): boolean;
35
+ /**
36
+ * Same sentence as the backend's risk detail line (risk-engine.ts):
37
+ * "Tether blacklisted tokens held by this contract" — issuers de-duplicated.
38
+ */
39
+ export declare function ownTokenContractFreezeText(items: ReadonlyArray<Pick<EvidenceItem, "authority">>): string;
26
40
  /** "⛔ Sanctioned — US OFAC · … " / "🧊 Frozen by issuer — Tether (USDT, TRON) · … Not a sanctions designation." */
27
41
  export declare function evidenceText(e: EvidenceItem): string | null;
28
- /** Designations first, then by strength of proof; unknown classes dropped. */
42
+ /**
43
+ * Designations first, then by strength of proof; unknown classes dropped.
44
+ * Freezes of tokens held by the looked-up token contract itself close the
45
+ * list as one line in the backend's wording — never a "Frozen by issuer" line.
46
+ */
29
47
  export declare function evidenceLines(items: ReadonlyArray<EvidenceItem> | null | undefined): string[];
package/dist/evidence.js CHANGED
@@ -49,10 +49,24 @@ function attributionLine(e) {
49
49
  head = `${head} (${date})`;
50
50
  return e.subject ? `${head}: ${e.subject}` : head;
51
51
  }
52
+ /** True for the one freeze that says nothing about the address as a counterparty. */
53
+ export function isOwnTokenContractFreeze(e) {
54
+ return e.class === "issuer_freeze" && e.held_by_token_contract === true;
55
+ }
56
+ /**
57
+ * Same sentence as the backend's risk detail line (risk-engine.ts):
58
+ * "Tether blacklisted tokens held by this contract" — issuers de-duplicated.
59
+ */
60
+ export function ownTokenContractFreezeText(items) {
61
+ const issuers = [...new Set(items.map((e) => e.authority))].join(", ");
62
+ return `${issuers} blacklisted tokens held by this contract`;
63
+ }
52
64
  /** "⛔ Sanctioned — US OFAC · … " / "🧊 Frozen by issuer — Tether (USDT, TRON) · … Not a sanctions designation." */
53
65
  export function evidenceText(e) {
54
66
  if (!isEvidenceClass(e.class))
55
67
  return null;
68
+ if (isOwnTokenContractFreeze(e))
69
+ return ownTokenContractFreezeText([e]);
56
70
  const { icon, title } = TITLES[e.class];
57
71
  const date = day(e.document_date);
58
72
  let line;
@@ -76,13 +90,22 @@ export function evidenceText(e) {
76
90
  const caveat = e.class === "sanctions_designation" ? null : NOT_A_SANCTIONS_DESIGNATION;
77
91
  return `${icon} ${caveat ? `${title} — ${line}. ${caveat}` : `${title} — ${line}`}`;
78
92
  }
79
- /** Designations first, then by strength of proof; unknown classes dropped. */
93
+ /**
94
+ * Designations first, then by strength of proof; unknown classes dropped.
95
+ * Freezes of tokens held by the looked-up token contract itself close the
96
+ * list as one line in the backend's wording — never a "Frozen by issuer" line.
97
+ */
80
98
  export function evidenceLines(items) {
81
99
  if (!items?.length)
82
100
  return [];
83
101
  const rank = (c) => EVIDENCE_CLASSES.indexOf(c);
84
- return [...items]
85
- .filter((e) => isEvidenceClass(e.class))
102
+ const known = items.filter((e) => isEvidenceClass(e.class));
103
+ const own = known.filter(isOwnTokenContractFreeze);
104
+ const lines = known
105
+ .filter((e) => !isOwnTokenContractFreeze(e))
86
106
  .sort((a, b) => rank(a.class) - rank(b.class))
87
107
  .map((e) => `- ${evidenceText(e)}`);
108
+ if (own.length)
109
+ lines.push(`- ${ownTokenContractFreezeText(own)}`);
110
+ return lines;
88
111
  }
package/dist/index.js CHANGED
@@ -19,12 +19,15 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
19
19
  import { z } from "zod";
20
20
  import { apiErrorMessage, formatRiskLevel, lookupRiskLines, traceStatusLine } from "./verdicts.js";
21
21
  import { evidenceLines } from "./evidence.js";
22
+ import { entityLine } from "./entity.js";
23
+ import { ADDRESS_FORMS, normalizeAddr } from "./address.js";
24
+ import { DATABASE_CANON } from "./canon.js";
22
25
  // ── Config ────────────────────────────────────────────────────────────────────
23
26
  const API_KEY = process.env.CHAINHINT_API_KEY;
24
27
  const BASE_URL = process.env.CHAINHINT_API_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co/functions/v1";
25
28
  const SUPABASE_URL = process.env.CHAINHINT_SUPABASE_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co";
26
29
  const SUPABASE_ANON_KEY = process.env.CHAINHINT_SUPABASE_ANON_KEY ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtqaXdmd3ltbnV6eHJpb2toY2prIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI3MzkxODgsImV4cCI6MjA4ODMxNTE4OH0.VqzzF_jI8zF072cbjWEDbYo3PnMDlIPy621iWkXEqyo";
27
- const VERSION = "1.3.3";
30
+ const VERSION = "1.3.4";
28
31
  const USER_AGENT = `chainhint-mcp/${VERSION}`;
29
32
  const FREE_CHECKS_PER_DAY = 3;
30
33
  const UPGRADE_HINT = "set CHAINHINT_API_KEY (Agency plan, https://chainhint.com/pricing) for 10,000/day";
@@ -165,12 +168,6 @@ function endpointBucket(category, type, name) {
165
168
  return "defi";
166
169
  return "unknown";
167
170
  }
168
- // EVM addresses are case-insensitive and stored lowercase; base58 chains
169
- // (BTC/TRON/SOL/TON) are case-sensitive — never lowercase those.
170
- function normalizeAddr(addr) {
171
- const a = addr.trim();
172
- return a.startsWith("0x") ? a.toLowerCase() : a;
173
- }
174
171
  function formatExposure(label, buckets) {
175
172
  if (!buckets?.length)
176
173
  return [];
@@ -221,8 +218,8 @@ const server = new McpServer({
221
218
  version: VERSION,
222
219
  });
223
220
  // ── Tool 1: check_wallet_risk ─────────────────────────────────────────────────
224
- 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.", {
225
- address: z.string().describe("Wallet address to check (EVM 0x..., Bitcoin, or Solana)"),
221
+ server.tool("check_wallet_risk", `Fast risk check for a crypto wallet address against ChainHint's entity database ${DATABASE_CANON}. 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.`, {
222
+ address: z.string().describe(`Wallet address to check: ${ADDRESS_FORMS}`),
226
223
  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)"),
227
224
  }, async ({ address, chain }) => {
228
225
  try {
@@ -237,13 +234,13 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
237
234
  `**Chain:** ${d.chain}`,
238
235
  ];
239
236
  // Canon (same as chainhint.com and the TG bot): an address that is not in
240
- // the labeled database has NO DATA — that is not evidence it is clean.
237
+ // the entity database has NO DATA — that is not evidence it is clean.
241
238
  // The API still returns risk_score 0 / "clean" for not-found, so the
242
239
  // wording is fixed here, and public incidents are cross-checked so a
243
240
  // known hack attacker that never got an `addresses` row is not
244
241
  // presented as unknown.
245
242
  if (!d.found_in_db) {
246
- lines.push(`**Risk:** ⚪ NO DATA — address is not in ChainHint's labeled database. This is not evidence it is clean.`);
243
+ lines.push(`**Risk:** ⚪ NO DATA — address is not in ChainHint's entity database. This is not evidence it is clean.`);
247
244
  const inc = await findPublicIncidentByAttacker(d.address);
248
245
  if (inc) {
249
246
  const attribution = attackerAttribution({ attacker_address: d.address, ...inc });
@@ -266,9 +263,8 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
266
263
  if (walletEvidence.length)
267
264
  lines.push(`**Evidence:**`, ...walletEvidence);
268
265
  if (d.entity?.name) {
269
- const sub = d.entity.subcategory ? ` / ${d.entity.subcategory}` : "";
270
- const ver = d.entity.verified ? ", verified" : "";
271
- lines.push(`**Entity:** ${d.entity.name} (${d.entity.category}${sub}${ver})`);
266
+ // An unconfirmed attribution is marked; the API's top-level category is then "unknown".
267
+ lines.push(entityLine(d.entity, { showVerified: true }));
272
268
  }
273
269
  else if (d.category) {
274
270
  lines.push(`**Category:** ${d.category}`);
@@ -300,7 +296,7 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
300
296
  });
301
297
  // ── Tool 2: lookup_address ────────────────────────────────────────────────────
302
298
  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.", {
303
- address: z.string().describe("Blockchain address to look up"),
299
+ address: z.string().describe(`Blockchain address to look up: ${ADDRESS_FORMS}`),
304
300
  chain: z.string().optional().describe("Blockchain (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, gnosis, bitcoin, solana, tron, ton)"),
305
301
  }, async ({ address, chain }) => {
306
302
  try {
@@ -319,9 +315,7 @@ server.tool("lookup_address", "Detailed lookup of a blockchain address: entity a
319
315
  `**Type:** ${d.is_contract ? "Smart Contract" : "EOA (wallet)"}`,
320
316
  ];
321
317
  if (d.entity?.name) {
322
- const sub = d.entity.subcategory ? ` / ${d.entity.subcategory}` : "";
323
- const conf = d.entity.confidence != null ? `, confidence ${Math.round(d.entity.confidence * 100)}%` : "";
324
- lines.push(`**Entity:** ${d.entity.name} (${d.entity.category}${sub}${conf})`);
318
+ lines.push(entityLine(d.entity));
325
319
  }
326
320
  else {
327
321
  lines.push(`**Entity:** Unknown / unlabeled`);
@@ -19,6 +19,13 @@ export declare function isTraceableChain(chain: string | null | undefined): bool
19
19
  /** Same sentence as the backend's tracingUnavailableMessage. */
20
20
  export declare function tracingUnavailableMessage(chain: string | null | undefined): string;
21
21
  export declare function formatRiskLevel(score: number): string;
22
+ /**
23
+ * Risk factors the API still returns in the response shape but no longer
24
+ * scores (always false). Never printed, whatever an old or cached response says.
25
+ * contract_not_verified: retired 2026-09-15 (risk-engine.ts) — the contract
26
+ * flag it read was wrong on every audited row.
27
+ */
28
+ export declare const RETIRED_RISK_FACTORS: readonly string[];
22
29
  export type DataUnavailable = {
23
30
  provider?: string;
24
31
  reason?: string;
package/dist/verdicts.js CHANGED
@@ -39,6 +39,13 @@ export function formatRiskLevel(score) {
39
39
  return "LOW";
40
40
  return "CLEAN";
41
41
  }
42
+ /**
43
+ * Risk factors the API still returns in the response shape but no longer
44
+ * scores (always false). Never printed, whatever an old or cached response says.
45
+ * contract_not_verified: retired 2026-09-15 (risk-engine.ts) — the contract
46
+ * flag it read was wrong on every audited row.
47
+ */
48
+ export const RETIRED_RISK_FACTORS = ["contract_not_verified"];
42
49
  function unavailableDetail(d) {
43
50
  const provider = d.data_availability?.provider ?? d.exposure?.data_unavailable?.provider;
44
51
  return d.data_availability?.detail
@@ -63,7 +70,9 @@ export function lookupRiskLines(d) {
63
70
  return [];
64
71
  const level = (d.risk.level ?? formatRiskLevel(d.risk.score)).toUpperCase();
65
72
  const lines = [`**Risk Score:** ${d.risk.score}/100 — **${level}**`];
66
- const factors = Object.entries(d.risk.factors ?? {}).filter(([, v]) => v).map(([k]) => k);
73
+ const factors = Object.entries(d.risk.factors ?? {})
74
+ .filter(([k, v]) => v === true && !RETIRED_RISK_FACTORS.includes(k))
75
+ .map(([k]) => k);
67
76
  if (factors.length)
68
77
  lines.push(`**Risk Factors:** ${factors.join(", ")}`);
69
78
  if (d.risk.details?.length)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "chainhint-mcp",
3
- "version": "1.3.3",
4
- "description": "ChainHint MCP server \u2014 crypto risk intelligence tools for Claude Desktop and Cursor",
3
+ "version": "1.3.4",
4
+ "description": "ChainHint MCP server crypto risk intelligence tools for Claude Desktop and Cursor",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",