chainhint-mcp 1.3.2 → 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 +9 -1
- package/dist/address.d.ts +20 -0
- package/dist/address.js +34 -0
- package/dist/canon.d.ts +12 -0
- package/dist/canon.js +12 -0
- package/dist/entity.d.ts +40 -0
- package/dist/entity.js +57 -0
- package/dist/evidence.d.ts +47 -0
- package/dist/evidence.js +111 -0
- package/dist/index.js +23 -20
- package/dist/verdicts.d.ts +7 -0
- package/dist/verdicts.js +10 -1
- package/package.json +2 -2
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
|
|
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)";
|
package/dist/address.js
ADDED
|
@@ -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)";
|
package/dist/canon.d.ts
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 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})`;
|
package/dist/entity.d.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Address evidence wording — MIRROR of the main repo's
|
|
3
|
+
* backend/supabase/functions/_shared/evidence-labels.ts (migration 129).
|
|
4
|
+
* Change both or neither; test/evidence.test.mjs pins the strings.
|
|
5
|
+
*
|
|
6
|
+
* Rule (owner decision 2026-09-14): "Sanctioned" is printed only for a
|
|
7
|
+
* sanctions designation. A law-enforcement attribution, an issuer freeze, a
|
|
8
|
+
* national seizure order and an industry blocklist each say what they are and
|
|
9
|
+
* that they are not a sanctions designation.
|
|
10
|
+
*/
|
|
11
|
+
export declare const EVIDENCE_CLASSES: readonly ["sanctions_designation", "law_enforcement_attribution", "issuer_freeze", "national_seizure_order", "industry_blocklist", "analyst_verified"];
|
|
12
|
+
export type EvidenceClass = typeof EVIDENCE_CLASSES[number];
|
|
13
|
+
export interface EvidenceItem {
|
|
14
|
+
class: EvidenceClass | string;
|
|
15
|
+
authority: string;
|
|
16
|
+
subject?: string | null;
|
|
17
|
+
document_ref?: string | null;
|
|
18
|
+
document_url?: string | null;
|
|
19
|
+
document_date?: string | null;
|
|
20
|
+
legal_basis?: string | null;
|
|
21
|
+
token?: string | null;
|
|
22
|
+
chain?: string | null;
|
|
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;
|
|
31
|
+
}
|
|
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;
|
|
40
|
+
/** "⛔ Sanctioned — US OFAC · … " / "🧊 Frozen by issuer — Tether (USDT, TRON) · … Not a sanctions designation." */
|
|
41
|
+
export declare function evidenceText(e: EvidenceItem): string | null;
|
|
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
|
+
*/
|
|
47
|
+
export declare function evidenceLines(items: ReadonlyArray<EvidenceItem> | null | undefined): string[];
|
package/dist/evidence.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Address evidence wording — MIRROR of the main repo's
|
|
3
|
+
* backend/supabase/functions/_shared/evidence-labels.ts (migration 129).
|
|
4
|
+
* Change both or neither; test/evidence.test.mjs pins the strings.
|
|
5
|
+
*
|
|
6
|
+
* Rule (owner decision 2026-09-14): "Sanctioned" is printed only for a
|
|
7
|
+
* sanctions designation. A law-enforcement attribution, an issuer freeze, a
|
|
8
|
+
* national seizure order and an industry blocklist each say what they are and
|
|
9
|
+
* that they are not a sanctions designation.
|
|
10
|
+
*/
|
|
11
|
+
export const EVIDENCE_CLASSES = [
|
|
12
|
+
"sanctions_designation",
|
|
13
|
+
"law_enforcement_attribution",
|
|
14
|
+
"issuer_freeze",
|
|
15
|
+
"national_seizure_order",
|
|
16
|
+
"industry_blocklist",
|
|
17
|
+
"analyst_verified",
|
|
18
|
+
];
|
|
19
|
+
export const NOT_A_SANCTIONS_DESIGNATION = "Not a sanctions designation.";
|
|
20
|
+
const TITLES = {
|
|
21
|
+
sanctions_designation: { icon: "⛔", title: "Sanctioned" },
|
|
22
|
+
law_enforcement_attribution: { icon: "🚩", title: "Law-enforcement attribution" },
|
|
23
|
+
issuer_freeze: { icon: "🧊", title: "Frozen by issuer" },
|
|
24
|
+
national_seizure_order: { icon: "🏛️", title: "National seizure order" },
|
|
25
|
+
industry_blocklist: { icon: "⚠️", title: "Industry blocklist" },
|
|
26
|
+
analyst_verified: { icon: "🔎", title: "Analyst-verified" },
|
|
27
|
+
};
|
|
28
|
+
const CHAIN_NAMES = { bsc: "BSC", tron: "TRON", ton: "TON" };
|
|
29
|
+
function isEvidenceClass(v) {
|
|
30
|
+
return typeof v === "string" && EVIDENCE_CLASSES.includes(v);
|
|
31
|
+
}
|
|
32
|
+
function chainName(chain) {
|
|
33
|
+
const c = (chain ?? "").trim().toLowerCase();
|
|
34
|
+
if (!c)
|
|
35
|
+
return "";
|
|
36
|
+
return CHAIN_NAMES[c] ?? c.charAt(0).toUpperCase() + c.slice(1);
|
|
37
|
+
}
|
|
38
|
+
function day(value) {
|
|
39
|
+
if (!value)
|
|
40
|
+
return null;
|
|
41
|
+
const m = /^(\d{4}-\d{2}-\d{2})/.exec(value.trim());
|
|
42
|
+
return m ? m[1] : null;
|
|
43
|
+
}
|
|
44
|
+
const join = (parts, sep) => parts.filter((p) => !!p && p.trim() !== "").join(sep);
|
|
45
|
+
function attributionLine(e) {
|
|
46
|
+
const date = day(e.document_date);
|
|
47
|
+
let head = join([e.authority, e.document_ref], ", ");
|
|
48
|
+
if (date)
|
|
49
|
+
head = `${head} (${date})`;
|
|
50
|
+
return e.subject ? `${head}: ${e.subject}` : head;
|
|
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
|
+
}
|
|
64
|
+
/** "⛔ Sanctioned — US OFAC · … " / "🧊 Frozen by issuer — Tether (USDT, TRON) · … Not a sanctions designation." */
|
|
65
|
+
export function evidenceText(e) {
|
|
66
|
+
if (!isEvidenceClass(e.class))
|
|
67
|
+
return null;
|
|
68
|
+
if (isOwnTokenContractFreeze(e))
|
|
69
|
+
return ownTokenContractFreezeText([e]);
|
|
70
|
+
const { icon, title } = TITLES[e.class];
|
|
71
|
+
const date = day(e.document_date);
|
|
72
|
+
let line;
|
|
73
|
+
switch (e.class) {
|
|
74
|
+
case "sanctions_designation":
|
|
75
|
+
line = join([e.authority, e.document_ref ?? e.legal_basis, date, e.subject], " · ");
|
|
76
|
+
break;
|
|
77
|
+
case "issuer_freeze": {
|
|
78
|
+
const what = join([e.token, chainName(e.chain)], ", ");
|
|
79
|
+
const when = date ?? join(["first seen by ChainHint", day(e.first_seen_at)], " ");
|
|
80
|
+
line = join([what ? `${e.authority} (${what})` : e.authority, when], " · ");
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case "law_enforcement_attribution":
|
|
84
|
+
case "national_seizure_order":
|
|
85
|
+
line = attributionLine(e);
|
|
86
|
+
break;
|
|
87
|
+
default:
|
|
88
|
+
line = join([e.authority, e.subject, date ?? (e.first_seen_at ? `first seen by ChainHint ${day(e.first_seen_at)}` : null)], " · ");
|
|
89
|
+
}
|
|
90
|
+
const caveat = e.class === "sanctions_designation" ? null : NOT_A_SANCTIONS_DESIGNATION;
|
|
91
|
+
return `${icon} ${caveat ? `${title} — ${line}. ${caveat}` : `${title} — ${line}`}`;
|
|
92
|
+
}
|
|
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
|
+
*/
|
|
98
|
+
export function evidenceLines(items) {
|
|
99
|
+
if (!items?.length)
|
|
100
|
+
return [];
|
|
101
|
+
const rank = (c) => EVIDENCE_CLASSES.indexOf(c);
|
|
102
|
+
const known = items.filter((e) => isEvidenceClass(e.class));
|
|
103
|
+
const own = known.filter(isOwnTokenContractFreeze);
|
|
104
|
+
const lines = known
|
|
105
|
+
.filter((e) => !isOwnTokenContractFreeze(e))
|
|
106
|
+
.sort((a, b) => rank(a.class) - rank(b.class))
|
|
107
|
+
.map((e) => `- ${evidenceText(e)}`);
|
|
108
|
+
if (own.length)
|
|
109
|
+
lines.push(`- ${ownTokenContractFreezeText(own)}`);
|
|
110
|
+
return lines;
|
|
111
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -18,12 +18,16 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
18
18
|
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
|
+
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";
|
|
21
25
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
22
26
|
const API_KEY = process.env.CHAINHINT_API_KEY;
|
|
23
27
|
const BASE_URL = process.env.CHAINHINT_API_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co/functions/v1";
|
|
24
28
|
const SUPABASE_URL = process.env.CHAINHINT_SUPABASE_URL ?? "https://kjiwfwymnuzxriokhcjk.supabase.co";
|
|
25
29
|
const SUPABASE_ANON_KEY = process.env.CHAINHINT_SUPABASE_ANON_KEY ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtqaXdmd3ltbnV6eHJpb2toY2prIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI3MzkxODgsImV4cCI6MjA4ODMxNTE4OH0.VqzzF_jI8zF072cbjWEDbYo3PnMDlIPy621iWkXEqyo";
|
|
26
|
-
const VERSION = "1.3.
|
|
30
|
+
const VERSION = "1.3.4";
|
|
27
31
|
const USER_AGENT = `chainhint-mcp/${VERSION}`;
|
|
28
32
|
const FREE_CHECKS_PER_DAY = 3;
|
|
29
33
|
const UPGRADE_HINT = "set CHAINHINT_API_KEY (Agency plan, https://chainhint.com/pricing) for 10,000/day";
|
|
@@ -164,12 +168,6 @@ function endpointBucket(category, type, name) {
|
|
|
164
168
|
return "defi";
|
|
165
169
|
return "unknown";
|
|
166
170
|
}
|
|
167
|
-
// EVM addresses are case-insensitive and stored lowercase; base58 chains
|
|
168
|
-
// (BTC/TRON/SOL/TON) are case-sensitive — never lowercase those.
|
|
169
|
-
function normalizeAddr(addr) {
|
|
170
|
-
const a = addr.trim();
|
|
171
|
-
return a.startsWith("0x") ? a.toLowerCase() : a;
|
|
172
|
-
}
|
|
173
171
|
function formatExposure(label, buckets) {
|
|
174
172
|
if (!buckets?.length)
|
|
175
173
|
return [];
|
|
@@ -220,8 +218,8 @@ const server = new McpServer({
|
|
|
220
218
|
version: VERSION,
|
|
221
219
|
});
|
|
222
220
|
// ── Tool 1: check_wallet_risk ─────────────────────────────────────────────────
|
|
223
|
-
server.tool("check_wallet_risk",
|
|
224
|
-
address: z.string().describe(
|
|
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}`),
|
|
225
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)"),
|
|
226
224
|
}, async ({ address, chain }) => {
|
|
227
225
|
try {
|
|
@@ -236,13 +234,13 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
|
|
|
236
234
|
`**Chain:** ${d.chain}`,
|
|
237
235
|
];
|
|
238
236
|
// Canon (same as chainhint.com and the TG bot): an address that is not in
|
|
239
|
-
// the
|
|
237
|
+
// the entity database has NO DATA — that is not evidence it is clean.
|
|
240
238
|
// The API still returns risk_score 0 / "clean" for not-found, so the
|
|
241
239
|
// wording is fixed here, and public incidents are cross-checked so a
|
|
242
240
|
// known hack attacker that never got an `addresses` row is not
|
|
243
241
|
// presented as unknown.
|
|
244
242
|
if (!d.found_in_db) {
|
|
245
|
-
lines.push(`**Risk:** ⚪ NO DATA — address is not in ChainHint's
|
|
243
|
+
lines.push(`**Risk:** ⚪ NO DATA — address is not in ChainHint's entity database. This is not evidence it is clean.`);
|
|
246
244
|
const inc = await findPublicIncidentByAttacker(d.address);
|
|
247
245
|
if (inc) {
|
|
248
246
|
const attribution = attackerAttribution({ attacker_address: d.address, ...inc });
|
|
@@ -256,13 +254,17 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
|
|
|
256
254
|
else {
|
|
257
255
|
lines.push(`**Risk Score:** ${d.risk_score}/100 — **${(d.risk_level ?? formatRiskLevel(d.risk_score)).toUpperCase()}**`);
|
|
258
256
|
}
|
|
259
|
-
|
|
260
|
-
|
|
257
|
+
// sanctions.hit comes only from a sanctions designation (API since migration 129);
|
|
258
|
+
// the evidence lines say which authority and that other classes are not sanctions.
|
|
259
|
+
const walletEvidence = evidenceLines(d.evidence);
|
|
260
|
+
if (d.sanctions?.hit && !walletEvidence.some((l) => l.startsWith("- ⛔"))) {
|
|
261
|
+
lines.push(`⛔ **SANCTIONED**`);
|
|
261
262
|
}
|
|
263
|
+
if (walletEvidence.length)
|
|
264
|
+
lines.push(`**Evidence:**`, ...walletEvidence);
|
|
262
265
|
if (d.entity?.name) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
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 }));
|
|
266
268
|
}
|
|
267
269
|
else if (d.category) {
|
|
268
270
|
lines.push(`**Category:** ${d.category}`);
|
|
@@ -294,7 +296,7 @@ server.tool("check_wallet_risk", "Fast risk check for a crypto wallet address ag
|
|
|
294
296
|
});
|
|
295
297
|
// ── Tool 2: lookup_address ────────────────────────────────────────────────────
|
|
296
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.", {
|
|
297
|
-
address: z.string().describe(
|
|
299
|
+
address: z.string().describe(`Blockchain address to look up: ${ADDRESS_FORMS}`),
|
|
298
300
|
chain: z.string().optional().describe("Blockchain (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche, gnosis, bitcoin, solana, tron, ton)"),
|
|
299
301
|
}, async ({ address, chain }) => {
|
|
300
302
|
try {
|
|
@@ -313,9 +315,7 @@ server.tool("lookup_address", "Detailed lookup of a blockchain address: entity a
|
|
|
313
315
|
`**Type:** ${d.is_contract ? "Smart Contract" : "EOA (wallet)"}`,
|
|
314
316
|
];
|
|
315
317
|
if (d.entity?.name) {
|
|
316
|
-
|
|
317
|
-
const conf = d.entity.confidence != null ? `, confidence ${Math.round(d.entity.confidence * 100)}%` : "";
|
|
318
|
-
lines.push(`**Entity:** ${d.entity.name} (${d.entity.category}${sub}${conf})`);
|
|
318
|
+
lines.push(entityLine(d.entity));
|
|
319
319
|
}
|
|
320
320
|
else {
|
|
321
321
|
lines.push(`**Entity:** Unknown / unlabeled`);
|
|
@@ -328,6 +328,9 @@ server.tool("lookup_address", "Detailed lookup of a blockchain address: entity a
|
|
|
328
328
|
lines.push(`**Labels:** ${[...new Set(d.labels)].join(", ")}`);
|
|
329
329
|
// Never a score or "CLEAN" when there was nothing to score (verdicts.ts).
|
|
330
330
|
lines.push(...lookupRiskLines(d));
|
|
331
|
+
const lookupEvidence = evidenceLines(d.evidence);
|
|
332
|
+
if (lookupEvidence.length)
|
|
333
|
+
lines.push(`**Evidence:**`, ...lookupEvidence);
|
|
331
334
|
const sanctionIds = d.sanctions?.identifications ?? [];
|
|
332
335
|
if (sanctionIds.length) {
|
|
333
336
|
const names = sanctionIds.map((s) => [s.name, s.program].filter(Boolean).join(" / ")).filter(Boolean);
|
package/dist/verdicts.d.ts
CHANGED
|
@@ -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 ?? {})
|
|
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.
|
|
4
|
-
"description": "ChainHint MCP server
|
|
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",
|