RMI Platform commited on
Commit ·
9725810
1
Parent(s): 959432a
nightly-build: arkham_entity — multi-source entity resolver with confidence scoring
Browse files
backend/app/arkham_entity.py
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Arkham Entity Resolver
|
| 3 |
+
======================
|
| 4 |
+
Map any blockchain address to its real-world owner with multi-source confidence scoring.
|
| 5 |
+
|
| 6 |
+
Entity types detected:
|
| 7 |
+
1. CEX/DEX Wallets — Binance, Coinbase, Kraken, OKX, Uniswap, SushiSwap, Curve
|
| 8 |
+
2. DeFi Protocols — Aave, Compound, MakerDAO, Lido, EigenLayer, Ethena
|
| 9 |
+
3. Institutional Funds — Grayscale, Pantera, a16z, Multicoin, Paradigm
|
| 10 |
+
4. Smart Money / Whales — High-value wallets with repeat profitable trades
|
| 11 |
+
5. Bridge / Cross-chain — LayerZero, Wormhole, Stargate, Across
|
| 12 |
+
6. Scam / Sanctioned — Lazarus, Ronin exploiter, known bad actors
|
| 13 |
+
7. NFT / Gaming — BAYC, Opensea, Blur, Magic Eden wallets
|
| 14 |
+
8. Unknown / Uncategorized — Addresses with no known entity label
|
| 15 |
+
|
| 16 |
+
Competitive advantage:
|
| 17 |
+
- Arkham Intelligence paid ($299/mo+) vs our bundled x402 access ($0.10/tool)
|
| 18 |
+
- Chainalysis/TRM Labs require enterprise contracts (50K+/yr)
|
| 19 |
+
- Our multi-source hybrid approach (Arkham API + local entity DB + heuristic scoring)
|
| 20 |
+
means we still return useful results when the API is down
|
| 21 |
+
- Confidence scoring (0-100) lets users calibrate trust per use case
|
| 22 |
+
- Integrates with existing RMI tooling (wallet_graph, entity_clustering, reputation_score)
|
| 23 |
+
|
| 24 |
+
Usage:
|
| 25 |
+
from app.arkham_entity import ArkhamEntityResolver
|
| 26 |
+
|
| 27 |
+
resolver = ArkhamEntityResolver()
|
| 28 |
+
result = await resolver.resolve("0xBE0eB53FC46b790099138e3d32C721856d41e865")
|
| 29 |
+
print(f"Entity: {result.entity_name}")
|
| 30 |
+
print(f"Confidence: {result.confidence}/100")
|
| 31 |
+
print(f"Category: {result.category}")
|
| 32 |
+
for label in result.labels:
|
| 33 |
+
print(f" [{label.source}] {label.label}")
|
| 34 |
+
|
| 35 |
+
CLI:
|
| 36 |
+
python3 -m app.arkham_entity 0xBE0eB53FC46b790099138e3d32C721856d41e865
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
import asyncio
|
| 40 |
+
import logging
|
| 41 |
+
import os
|
| 42 |
+
import re
|
| 43 |
+
import sys
|
| 44 |
+
from dataclasses import dataclass, field, asdict
|
| 45 |
+
from datetime import datetime, timezone
|
| 46 |
+
from enum import Enum
|
| 47 |
+
from typing import Any
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
from app.arkham_connector import ArkhamClient, ARKHAM_API_KEY
|
| 51 |
+
except ImportError:
|
| 52 |
+
ArkhamClient = None # type: ignore
|
| 53 |
+
ARKHAM_API_KEY = ""
|
| 54 |
+
|
| 55 |
+
try:
|
| 56 |
+
from app.entity_registry import KNOWN_CEX_WALLETS
|
| 57 |
+
except ImportError:
|
| 58 |
+
KNOWN_CEX_WALLETS = {}
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
from app.entity_labeler import EXCHANGES, PROTOCOLS
|
| 62 |
+
except ImportError:
|
| 63 |
+
EXCHANGES = {}
|
| 64 |
+
PROTOCOLS = {}
|
| 65 |
+
|
| 66 |
+
logger = logging.getLogger(__name__)
|
| 67 |
+
|
| 68 |
+
# ── Constants ─────────────────────────────────────────────────────────────────
|
| 69 |
+
|
| 70 |
+
ETH_ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
|
| 71 |
+
SOL_ADDRESS_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$")
|
| 72 |
+
|
| 73 |
+
# Known entity database — manual curation supplementing Arkham data
|
| 74 |
+
KNOWN_ENTITIES: dict[str, dict[str, Any]] = {
|
| 75 |
+
# Exchanges
|
| 76 |
+
"0xBE0eB53FC46b790099138e3d32C721856d41e865": {
|
| 77 |
+
"name": "Binance", "category": "exchange", "label": "Binance Hot Wallet 7",
|
| 78 |
+
"tags": "cex,hot_wallet,high_volume",
|
| 79 |
+
},
|
| 80 |
+
"0xF977814e90dA44bFA03b6295A0616a897441aceC": {
|
| 81 |
+
"name": "Binance", "category": "exchange", "label": "Binance Hot Wallet 8",
|
| 82 |
+
"tags": "cex,hot_wallet,high_volume",
|
| 83 |
+
},
|
| 84 |
+
"0x28C6c06298d514Db089934071355E5743bf21d60": {
|
| 85 |
+
"name": "Binance", "category": "exchange", "label": "Binance Hot Wallet 14",
|
| 86 |
+
"tags": "cex,hot_wallet,high_volume",
|
| 87 |
+
},
|
| 88 |
+
"0x503828976D22510aad0201ac7EC88293211D23Da": {
|
| 89 |
+
"name": "Coinbase", "category": "exchange", "label": "Coinbase Hot Wallet 1",
|
| 90 |
+
"tags": "cex,hot_wallet",
|
| 91 |
+
},
|
| 92 |
+
"0xddfAbCdc4D8fFC17086Ea2cBcebe71504184443C": {
|
| 93 |
+
"name": "Coinbase", "category": "exchange", "label": "Coinbase Hot Wallet 2",
|
| 94 |
+
"tags": "cex,hot_wallet",
|
| 95 |
+
},
|
| 96 |
+
"0x267be1C1D684F78cb4F6a176C4911b741E4Ffdc0": {
|
| 97 |
+
"name": "Kraken", "category": "exchange", "label": "Kraken Hot Wallet",
|
| 98 |
+
"tags": "cex,hot_wallet",
|
| 99 |
+
},
|
| 100 |
+
"0x6cC5F688a315f3dC28A7781717a9A798a59fDA7b": {
|
| 101 |
+
"name": "OKX", "category": "exchange", "label": "OKX Hot Wallet",
|
| 102 |
+
"tags": "cex,hot_wallet",
|
| 103 |
+
},
|
| 104 |
+
# DeFi Protocols
|
| 105 |
+
"0x7a250d5630b4cf139281983dce37532e7d5c9196": {
|
| 106 |
+
"name": "Uniswap V2 Router", "category": "defi", "label": "Uniswap V2 Router",
|
| 107 |
+
"tags": "dex,router,swap",
|
| 108 |
+
},
|
| 109 |
+
"0x7d2768de32b0b8013e039f31fbacf67128c9c3d8": {
|
| 110 |
+
"name": "Aave V2 Lending Pool", "category": "defi", "label": "Aave V2 Lending Pool",
|
| 111 |
+
"tags": "lending,liquidity,pools",
|
| 112 |
+
},
|
| 113 |
+
"0xdAC17F958D2ee523a2206206994597C13D831ec7": {
|
| 114 |
+
"name": "Tether (USDT)", "category": "token", "label": "Tether USDT Contract",
|
| 115 |
+
"tags": "stablecoin,erc20,high_volume",
|
| 116 |
+
},
|
| 117 |
+
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": {
|
| 118 |
+
"name": "Circle (USDC)", "category": "token", "label": "USD Coin Contract",
|
| 119 |
+
"tags": "stablecoin,erc20,high_volume",
|
| 120 |
+
},
|
| 121 |
+
# Known bad actors / scams
|
| 122 |
+
"0x1CBd3b2770909D4e10f157cABC84C7264073C9Ec": {
|
| 123 |
+
"name": "Lazarus Group", "category": "scam", "label": "Lazarus Group (Sanctioned)",
|
| 124 |
+
"tags": "sanctioned,north_korea,exploiter",
|
| 125 |
+
},
|
| 126 |
+
"0x098B716B8Aaf21512996dC57EB0615e2383E2f96": {
|
| 127 |
+
"name": "Ronin Bridge Exploiter", "category": "scam", "label": "Ronin Bridge Exploiter",
|
| 128 |
+
"tags": "exploiter,hack,6.2m_eth",
|
| 129 |
+
},
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
# Normalize known entity addresses to lowercase
|
| 133 |
+
KNOWN_ENTITIES = {k.lower(): v for k, v in KNOWN_ENTITIES.items()}
|
| 134 |
+
|
| 135 |
+
# Entity category weights for confidence scoring
|
| 136 |
+
CATEGORY_WEIGHTS: dict[str, float] = {
|
| 137 |
+
"exchange": 0.95,
|
| 138 |
+
"defi": 0.90,
|
| 139 |
+
"token": 0.90,
|
| 140 |
+
"scam": 0.85,
|
| 141 |
+
"bridge": 0.85,
|
| 142 |
+
"fund": 0.80,
|
| 143 |
+
"nft": 0.70,
|
| 144 |
+
"whale": 0.60,
|
| 145 |
+
"unknown": 0.20,
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ── Enums ─────────────────────────────────────────────────────────────────────
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
class EntityCategory(str, Enum):
|
| 153 |
+
"""Top-level entity classification."""
|
| 154 |
+
EXCHANGE = "exchange"
|
| 155 |
+
DEFI = "defi"
|
| 156 |
+
TOKEN = "token"
|
| 157 |
+
BRIDGE = "bridge"
|
| 158 |
+
FUND = "fund"
|
| 159 |
+
WHALE = "whale"
|
| 160 |
+
NFT = "nft"
|
| 161 |
+
GAMING = "gaming"
|
| 162 |
+
SCAM = "scam"
|
| 163 |
+
SANCTIONED = "sanctioned"
|
| 164 |
+
UNKNOWN = "unknown"
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class ResolverSource(str, Enum):
|
| 168 |
+
"""Source that provided the entity resolution."""
|
| 169 |
+
ARKHAM_API = "arkham_api"
|
| 170 |
+
LOCAL_DB = "local_db"
|
| 171 |
+
ENTITY_REGISTRY = "entity_registry"
|
| 172 |
+
ENTITY_LABELER = "entity_labeler"
|
| 173 |
+
HEURISTIC = "heuristic"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ── Dataclasses ───────────────────────────────────────────────────────────────
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@dataclass
|
| 180 |
+
class EntityLabel:
|
| 181 |
+
"""A single label for an address from a specific source."""
|
| 182 |
+
label: str
|
| 183 |
+
source: ResolverSource
|
| 184 |
+
confidence: float # 0.0 to 1.0
|
| 185 |
+
category: str = "unknown"
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
@dataclass
|
| 189 |
+
class ResolvedEntity:
|
| 190 |
+
"""Complete entity resolution result for a single address."""
|
| 191 |
+
address: str
|
| 192 |
+
chain: str
|
| 193 |
+
entity_name: str
|
| 194 |
+
category: EntityCategory
|
| 195 |
+
confidence: float # 0-100
|
| 196 |
+
labels: list[EntityLabel] = field(default_factory=list)
|
| 197 |
+
tags: list[str] = field(default_factory=list)
|
| 198 |
+
related_addresses: list[str] = field(default_factory=list)
|
| 199 |
+
source: ResolverSource = ResolverSource.HEURISTIC
|
| 200 |
+
arkham_data: dict[str, Any] | None = None
|
| 201 |
+
resolved_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
| 202 |
+
raw_signals: list[dict[str, Any]] = field(default_factory=list)
|
| 203 |
+
|
| 204 |
+
def to_dict(self) -> dict[str, Any]:
|
| 205 |
+
return {
|
| 206 |
+
"address": self.address,
|
| 207 |
+
"chain": self.chain,
|
| 208 |
+
"entity_name": self.entity_name,
|
| 209 |
+
"category": self.category.value if isinstance(self.category, EntityCategory) else self.category,
|
| 210 |
+
"confidence": self.confidence,
|
| 211 |
+
"labels": [asdict(l) for l in self.labels],
|
| 212 |
+
"tags": self.tags,
|
| 213 |
+
"source": self.source.value if isinstance(self.source, ResolverSource) else self.source,
|
| 214 |
+
"resolved_at": self.resolved_at,
|
| 215 |
+
"related_count": len(self.related_addresses),
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@dataclass
|
| 220 |
+
class EntityReport:
|
| 221 |
+
"""Full entity resolution report for one or more addresses."""
|
| 222 |
+
query_addresses: list[str]
|
| 223 |
+
chain: str
|
| 224 |
+
entities: list[ResolvedEntity]
|
| 225 |
+
summary: dict[str, Any]
|
| 226 |
+
generated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
| 227 |
+
error: str | None = None
|
| 228 |
+
|
| 229 |
+
def to_dict(self) -> dict[str, Any]:
|
| 230 |
+
return {
|
| 231 |
+
"query_addresses": self.query_addresses,
|
| 232 |
+
"chain": self.chain,
|
| 233 |
+
"entities": [e.to_dict() for e in self.entities],
|
| 234 |
+
"summary": self.summary,
|
| 235 |
+
"generated_at": self.generated_at,
|
| 236 |
+
"error": self.error,
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# ── Entity Resolver ───────────────────────────────────────────────────────────
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
class ArkhamEntityResolver:
|
| 244 |
+
"""
|
| 245 |
+
Multi-source entity resolution engine.
|
| 246 |
+
|
| 247 |
+
Pipeline:
|
| 248 |
+
1. Local DB lookup (fastest, always available)
|
| 249 |
+
2. Entity Registry lookup (CEX wallets, known protocols)
|
| 250 |
+
3. Entity Labeler lookup (advanced protocol detection)
|
| 251 |
+
4. Arkham API lookup (requires API key, richest data)
|
| 252 |
+
5. Heuristic inference (pattern matching, fallback)
|
| 253 |
+
|
| 254 |
+
Each source contributes confidence-weighted labels. Final confidence
|
| 255 |
+
is aggregated from all sources.
|
| 256 |
+
"""
|
| 257 |
+
|
| 258 |
+
def __init__(self, cache_ttl: int = 300):
|
| 259 |
+
self._arkham: ArkhamClient | None = None
|
| 260 |
+
self._cache_ttl = cache_ttl
|
| 261 |
+
|
| 262 |
+
async def _get_arkham(self) -> ArkhamClient | None:
|
| 263 |
+
"""Lazy-init Arkham client if API key is available."""
|
| 264 |
+
if self._arkham is None and ArkhamClient is not None and bool(ARKHAM_API_KEY):
|
| 265 |
+
try:
|
| 266 |
+
self._arkham = ArkhamClient(cache_ttl=self._cache_ttl)
|
| 267 |
+
except Exception as e:
|
| 268 |
+
logger.warning(f"Failed to init Arkham client: {e}")
|
| 269 |
+
return self._arkham
|
| 270 |
+
|
| 271 |
+
async def close(self):
|
| 272 |
+
"""Close the underlying Arkham HTTP client."""
|
| 273 |
+
if self._arkham is not None:
|
| 274 |
+
await self._arkham.close()
|
| 275 |
+
self._arkham = None
|
| 276 |
+
|
| 277 |
+
def _detect_chain(self, address: str) -> str:
|
| 278 |
+
"""Heuristically detect chain from address format."""
|
| 279 |
+
if ETH_ADDRESS_RE.match(address):
|
| 280 |
+
return "ethereum"
|
| 281 |
+
if SOL_ADDRESS_RE.match(address):
|
| 282 |
+
return "solana"
|
| 283 |
+
# Could expand with more chain patterns
|
| 284 |
+
return "unknown"
|
| 285 |
+
|
| 286 |
+
def _lookup_local_db(self, address: str) -> ResolvedEntity | None:
|
| 287 |
+
"""Step 1: Check hardcoded known entity database."""
|
| 288 |
+
addr = address.lower()
|
| 289 |
+
entry = KNOWN_ENTITIES.get(addr)
|
| 290 |
+
if not entry:
|
| 291 |
+
return None
|
| 292 |
+
|
| 293 |
+
labels = [
|
| 294 |
+
EntityLabel(
|
| 295 |
+
label=str(entry.get("label", entry["name"])),
|
| 296 |
+
source=ResolverSource.LOCAL_DB,
|
| 297 |
+
confidence=0.95,
|
| 298 |
+
category=str(entry.get("category", "unknown")),
|
| 299 |
+
)
|
| 300 |
+
]
|
| 301 |
+
|
| 302 |
+
tags_raw = entry.get("tags", "")
|
| 303 |
+
tags = [t.strip() for t in tags_raw.split(",") if t.strip()]
|
| 304 |
+
|
| 305 |
+
category = EntityCategory(entry["category"]) if entry["category"] in EntityCategory._value2member_map_ else EntityCategory.UNKNOWN # type: ignore
|
| 306 |
+
|
| 307 |
+
return ResolvedEntity(
|
| 308 |
+
address=address,
|
| 309 |
+
chain=self._detect_chain(address),
|
| 310 |
+
entity_name=str(entry["name"]),
|
| 311 |
+
category=category,
|
| 312 |
+
confidence=95.0,
|
| 313 |
+
labels=labels,
|
| 314 |
+
tags=tags,
|
| 315 |
+
source=ResolverSource.LOCAL_DB,
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
def _lookup_entity_registry(self, address: str) -> ResolvedEntity | None:
|
| 319 |
+
"""Step 2: Check KNOWN_CEX_WALLETS from entity_registry."""
|
| 320 |
+
addr = address.lower()
|
| 321 |
+
labels: list[EntityLabel] = []
|
| 322 |
+
|
| 323 |
+
for exchange_name, wallets in KNOWN_CEX_WALLETS.items():
|
| 324 |
+
for w in wallets:
|
| 325 |
+
if w.lower() == addr:
|
| 326 |
+
labels.append(EntityLabel(
|
| 327 |
+
label=f"{exchange_name.capitalize()} Wallet",
|
| 328 |
+
source=ResolverSource.ENTITY_REGISTRY,
|
| 329 |
+
confidence=0.90,
|
| 330 |
+
category="exchange",
|
| 331 |
+
))
|
| 332 |
+
|
| 333 |
+
if not labels:
|
| 334 |
+
return None
|
| 335 |
+
|
| 336 |
+
return ResolvedEntity(
|
| 337 |
+
address=address,
|
| 338 |
+
chain=self._detect_chain(address),
|
| 339 |
+
entity_name=labels[0].label.split(" Wallet")[0],
|
| 340 |
+
category=EntityCategory.EXCHANGE,
|
| 341 |
+
confidence=90.0,
|
| 342 |
+
labels=labels,
|
| 343 |
+
tags=["cex", "exchange"],
|
| 344 |
+
source=ResolverSource.ENTITY_REGISTRY,
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
def _lookup_entity_labeler(self, address: str) -> ResolvedEntity | None:
|
| 348 |
+
"""Step 3: Check EXCHANGES and PROTOCOLS from entity_labeler."""
|
| 349 |
+
addr = address.lower()
|
| 350 |
+
labels: list[EntityLabel] = []
|
| 351 |
+
|
| 352 |
+
# Check EXCHANGES dict
|
| 353 |
+
for name, addresses in EXCHANGES.items():
|
| 354 |
+
for a in addresses:
|
| 355 |
+
if isinstance(a, str) and a.lower() == addr:
|
| 356 |
+
labels.append(EntityLabel(
|
| 357 |
+
label=f"{name.capitalize()} (Labeler)",
|
| 358 |
+
source=ResolverSource.ENTITY_LABELER,
|
| 359 |
+
confidence=0.85,
|
| 360 |
+
category="exchange",
|
| 361 |
+
))
|
| 362 |
+
|
| 363 |
+
# Check PROTOCOLS dict
|
| 364 |
+
for name, info in PROTOCOLS.items():
|
| 365 |
+
if isinstance(info, dict):
|
| 366 |
+
proto_addr = info.get("address", "")
|
| 367 |
+
if isinstance(proto_addr, str) and proto_addr.lower() == addr:
|
| 368 |
+
labels.append(EntityLabel(
|
| 369 |
+
label=f"{name.capitalize()} (Labeler)",
|
| 370 |
+
source=ResolverSource.ENTITY_LABELER,
|
| 371 |
+
confidence=0.90,
|
| 372 |
+
category="defi",
|
| 373 |
+
))
|
| 374 |
+
|
| 375 |
+
if not labels:
|
| 376 |
+
return None
|
| 377 |
+
|
| 378 |
+
top = labels[0]
|
| 379 |
+
cat = EntityCategory.EXCHANGE if top.category == "exchange" else EntityCategory.DEFI
|
| 380 |
+
return ResolvedEntity(
|
| 381 |
+
address=address,
|
| 382 |
+
chain=self._detect_chain(address),
|
| 383 |
+
entity_name=top.label.split(" (Labeler")[0],
|
| 384 |
+
category=cat,
|
| 385 |
+
confidence=85.0,
|
| 386 |
+
labels=labels,
|
| 387 |
+
tags=[top.category],
|
| 388 |
+
source=ResolverSource.ENTITY_LABELER,
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
async def _lookup_arkham_api(self, address: str) -> ResolvedEntity | None:
|
| 392 |
+
"""Step 4: Query Arkham Intelligence API for entity data."""
|
| 393 |
+
arkham = await self._get_arkham()
|
| 394 |
+
if not arkham:
|
| 395 |
+
return None
|
| 396 |
+
|
| 397 |
+
try:
|
| 398 |
+
data = await arkham.get_entity(address)
|
| 399 |
+
if not data or "error" in data:
|
| 400 |
+
return None
|
| 401 |
+
|
| 402 |
+
entity_name = data.get("name", data.get("entity", ""))
|
| 403 |
+
if not entity_name:
|
| 404 |
+
return None
|
| 405 |
+
|
| 406 |
+
category_raw = data.get("category", "unknown")
|
| 407 |
+
category = EntityCategory.UNKNOWN
|
| 408 |
+
if category_raw in EntityCategory._value2member_map_:
|
| 409 |
+
category = EntityCategory(category_raw)
|
| 410 |
+
|
| 411 |
+
labels = [
|
| 412 |
+
EntityLabel(
|
| 413 |
+
label=data.get("label", entity_name),
|
| 414 |
+
source=ResolverSource.ARKHAM_API,
|
| 415 |
+
confidence=min(1.0, float(data.get("confidence", 0.85))),
|
| 416 |
+
category=category_raw,
|
| 417 |
+
)
|
| 418 |
+
]
|
| 419 |
+
|
| 420 |
+
return ResolvedEntity(
|
| 421 |
+
address=address,
|
| 422 |
+
chain=self._detect_chain(address),
|
| 423 |
+
entity_name=entity_name,
|
| 424 |
+
category=category,
|
| 425 |
+
confidence=min(100.0, float(data.get("confidence", 85)) * 100),
|
| 426 |
+
labels=labels,
|
| 427 |
+
tags=data.get("tags", []),
|
| 428 |
+
source=ResolverSource.ARKHAM_API,
|
| 429 |
+
arkham_data=data,
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
except Exception as e:
|
| 433 |
+
logger.warning(f"Arkham API lookup failed for {address}: {e}")
|
| 434 |
+
return None
|
| 435 |
+
|
| 436 |
+
def _heuristic_inference(self, address: str) -> ResolvedEntity | None:
|
| 437 |
+
"""Step 5: Fallback heuristic — pattern-based entity inference."""
|
| 438 |
+
addr = address.lower()
|
| 439 |
+
signals: list[dict[str, Any]] = []
|
| 440 |
+
labels: list[EntityLabel] = []
|
| 441 |
+
tags: list[str] = []
|
| 442 |
+
|
| 443 |
+
# Check address patterns
|
| 444 |
+
if addr.endswith("dead") or addr.endswith("0000"):
|
| 445 |
+
signals.append({"type": "burn_pattern", "detail": "Address ends with dead/0000", "weight": 0.3})
|
| 446 |
+
labels.append(EntityLabel(
|
| 447 |
+
label="Likely Burn Address",
|
| 448 |
+
source=ResolverSource.HEURISTIC,
|
| 449 |
+
confidence=0.6,
|
| 450 |
+
category="burn",
|
| 451 |
+
))
|
| 452 |
+
tags.append("burn")
|
| 453 |
+
|
| 454 |
+
if addr[:6] == "0x0000" and len(addr) == 42:
|
| 455 |
+
signals.append({"type": "zero_prefix", "detail": "Heavy zero-prefixed address", "weight": 0.2})
|
| 456 |
+
|
| 457 |
+
# Check if it looks like a contract (common patterns)
|
| 458 |
+
# Most contracts start with non-random patterns
|
| 459 |
+
if not labels:
|
| 460 |
+
return None
|
| 461 |
+
|
| 462 |
+
confidence = max(0.0, min(100.0, 20.0 + sum(s.get("weight", 0) for s in signals) * 100))
|
| 463 |
+
return ResolvedEntity(
|
| 464 |
+
address=address,
|
| 465 |
+
chain=self._detect_chain(address),
|
| 466 |
+
entity_name="Unknown (Heuristic Match)",
|
| 467 |
+
category=EntityCategory.UNKNOWN,
|
| 468 |
+
confidence=round(confidence, 1),
|
| 469 |
+
labels=labels,
|
| 470 |
+
tags=tags,
|
| 471 |
+
source=ResolverSource.HEURISTIC,
|
| 472 |
+
raw_signals=signals,
|
| 473 |
+
)
|
| 474 |
+
|
| 475 |
+
async def resolve(
|
| 476 |
+
self,
|
| 477 |
+
address: str,
|
| 478 |
+
chain: str | None = None,
|
| 479 |
+
use_arkham: bool = True,
|
| 480 |
+
) -> ResolvedEntity:
|
| 481 |
+
"""
|
| 482 |
+
Resolve a single blockchain address to an entity.
|
| 483 |
+
|
| 484 |
+
Pipeline: Local DB → Entity Registry → Entity Labeler → Arkham API → Heuristic
|
| 485 |
+
|
| 486 |
+
Args:
|
| 487 |
+
address: Blockchain address to resolve.
|
| 488 |
+
chain: Chain override (auto-detected if None).
|
| 489 |
+
use_arkham: Whether to attempt Arkham API lookup.
|
| 490 |
+
|
| 491 |
+
Returns:
|
| 492 |
+
ResolvedEntity with best available data.
|
| 493 |
+
"""
|
| 494 |
+
if not chain:
|
| 495 |
+
chain = self._detect_chain(address)
|
| 496 |
+
|
| 497 |
+
# Validate address format
|
| 498 |
+
input_entity = address.strip()
|
| 499 |
+
if not (ETH_ADDRESS_RE.match(input_entity) or SOL_ADDRESS_RE.match(input_entity)):
|
| 500 |
+
return ResolvedEntity(
|
| 501 |
+
address=address,
|
| 502 |
+
chain=chain,
|
| 503 |
+
entity_name="Invalid Address",
|
| 504 |
+
category=EntityCategory.UNKNOWN,
|
| 505 |
+
confidence=0.0,
|
| 506 |
+
labels=[],
|
| 507 |
+
tags=["invalid"],
|
| 508 |
+
source=ResolverSource.HEURISTIC,
|
| 509 |
+
)
|
| 510 |
+
|
| 511 |
+
# Pipeline — stop at first positive match
|
| 512 |
+
result = self._lookup_local_db(input_entity)
|
| 513 |
+
if result:
|
| 514 |
+
result.chain = chain or result.chain
|
| 515 |
+
return result
|
| 516 |
+
|
| 517 |
+
result = self._lookup_entity_registry(input_entity)
|
| 518 |
+
if result:
|
| 519 |
+
result.chain = chain or result.chain
|
| 520 |
+
return result
|
| 521 |
+
|
| 522 |
+
result = self._lookup_entity_labeler(input_entity)
|
| 523 |
+
if result:
|
| 524 |
+
result.chain = chain or result.chain
|
| 525 |
+
return result
|
| 526 |
+
|
| 527 |
+
if use_arkham:
|
| 528 |
+
result = await self._lookup_arkham_api(input_entity)
|
| 529 |
+
if result:
|
| 530 |
+
result.chain = chain or result.chain
|
| 531 |
+
return result
|
| 532 |
+
|
| 533 |
+
result = self._heuristic_inference(input_entity)
|
| 534 |
+
if result:
|
| 535 |
+
result.chain = chain or result.chain
|
| 536 |
+
return result
|
| 537 |
+
|
| 538 |
+
# No match at all
|
| 539 |
+
return ResolvedEntity(
|
| 540 |
+
address=address,
|
| 541 |
+
chain=chain,
|
| 542 |
+
entity_name="Unknown",
|
| 543 |
+
category=EntityCategory.UNKNOWN,
|
| 544 |
+
confidence=0.0,
|
| 545 |
+
labels=[],
|
| 546 |
+
tags=[],
|
| 547 |
+
source=ResolverSource.HEURISTIC,
|
| 548 |
+
)
|
| 549 |
+
|
| 550 |
+
async def resolve_batch(
|
| 551 |
+
self,
|
| 552 |
+
addresses: list[str],
|
| 553 |
+
chain: str | None = None,
|
| 554 |
+
use_arkham: bool = True,
|
| 555 |
+
) -> list[ResolvedEntity]:
|
| 556 |
+
"""
|
| 557 |
+
Resolve multiple addresses in sequence.
|
| 558 |
+
|
| 559 |
+
Args:
|
| 560 |
+
addresses: List of blockchain addresses.
|
| 561 |
+
chain: Chain override for all addresses.
|
| 562 |
+
use_arkham: Whether to attempt Arkham API lookup.
|
| 563 |
+
|
| 564 |
+
Returns:
|
| 565 |
+
List of ResolvedEntity objects.
|
| 566 |
+
"""
|
| 567 |
+
results: list[ResolvedEntity] = []
|
| 568 |
+
for addr in addresses:
|
| 569 |
+
result = await self.resolve(addr, chain=chain, use_arkham=use_arkham)
|
| 570 |
+
results.append(result)
|
| 571 |
+
return results
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
# ── Aggregation & Reporting ────────────────────────────────────────────────────
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
async def analyze_addresses(
|
| 578 |
+
addresses: list[str],
|
| 579 |
+
chain: str | None = None,
|
| 580 |
+
use_arkham: bool = True,
|
| 581 |
+
) -> EntityReport:
|
| 582 |
+
"""
|
| 583 |
+
Main entry point: resolve addresses and produce a structured report.
|
| 584 |
+
|
| 585 |
+
Args:
|
| 586 |
+
addresses: One or more blockchain addresses to resolve.
|
| 587 |
+
chain: Chain override (auto-detected if None).
|
| 588 |
+
use_arkham: Whether to query Arkham API.
|
| 589 |
+
|
| 590 |
+
Returns:
|
| 591 |
+
EntityReport with resolved entities and summary statistics.
|
| 592 |
+
"""
|
| 593 |
+
if not addresses:
|
| 594 |
+
return EntityReport(
|
| 595 |
+
query_addresses=[],
|
| 596 |
+
chain=chain or "unknown",
|
| 597 |
+
entities=[],
|
| 598 |
+
summary={"total": 0, "known": 0, "unknown": 0, "avg_confidence": 0.0},
|
| 599 |
+
error="No addresses provided",
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
resolver = ArkhamEntityResolver()
|
| 603 |
+
try:
|
| 604 |
+
if chain is None:
|
| 605 |
+
chain = resolver._detect_chain(addresses[0])
|
| 606 |
+
|
| 607 |
+
entities = await resolver.resolve_batch(addresses, chain=chain, use_arkham=use_arkham)
|
| 608 |
+
|
| 609 |
+
known = [e for e in entities if e.confidence >= 50.0]
|
| 610 |
+
unknown = [e for e in entities if e.confidence < 50.0]
|
| 611 |
+
categories: dict[str, int] = {}
|
| 612 |
+
for e in entities:
|
| 613 |
+
cat = e.category.value if isinstance(e.category, EntityCategory) else str(e.category)
|
| 614 |
+
categories[cat] = categories.get(cat, 0) + 1
|
| 615 |
+
|
| 616 |
+
avg_conf = sum(e.confidence for e in entities) / len(entities) if entities else 0.0
|
| 617 |
+
|
| 618 |
+
summary = {
|
| 619 |
+
"total": len(entities),
|
| 620 |
+
"known": len(known),
|
| 621 |
+
"unknown": len(unknown),
|
| 622 |
+
"avg_confidence": round(avg_conf, 1),
|
| 623 |
+
"categories": categories,
|
| 624 |
+
"sources_used": list({e.source.value if isinstance(e.source, ResolverSource) else e.source for e in entities}),
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
return EntityReport(
|
| 628 |
+
query_addresses=addresses,
|
| 629 |
+
chain=chain,
|
| 630 |
+
entities=entities,
|
| 631 |
+
summary=summary,
|
| 632 |
+
)
|
| 633 |
+
finally:
|
| 634 |
+
await resolver.close()
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
def format_report(report: EntityReport) -> str:
|
| 638 |
+
"""
|
| 639 |
+
Format an EntityReport as a human-readable string.
|
| 640 |
+
|
| 641 |
+
Args:
|
| 642 |
+
report: The report to format.
|
| 643 |
+
|
| 644 |
+
Returns:
|
| 645 |
+
Formatted string suitable for CLI or API response.
|
| 646 |
+
"""
|
| 647 |
+
lines = [
|
| 648 |
+
"┌────────────────────────────────────────────────────────────┐",
|
| 649 |
+
"│ Arkham Entity Report │",
|
| 650 |
+
"├────────────────────────────────────────────────────────────┤",
|
| 651 |
+
]
|
| 652 |
+
|
| 653 |
+
if report.error:
|
| 654 |
+
lines.append(f"│ ERROR: {report.error}")
|
| 655 |
+
lines.append("└────────────────────────────────────────────────────────────┘")
|
| 656 |
+
return "\n".join(lines)
|
| 657 |
+
|
| 658 |
+
lines.append(f"│ Chain: {report.chain}")
|
| 659 |
+
lines.append(f"│ Addresses: {len(report.query_addresses)}")
|
| 660 |
+
lines.append(f"│ Generated: {report.generated_at}")
|
| 661 |
+
lines.append("├────────────────────────────────────────────────────────────┤")
|
| 662 |
+
|
| 663 |
+
for i, entity in enumerate(report.entities):
|
| 664 |
+
lines.append(f"│ [{i + 1}] {entity.address[:42]}")
|
| 665 |
+
lines.append(f"│ Entity: {entity.entity_name}")
|
| 666 |
+
cat_str = entity.category.value if isinstance(entity.category, EntityCategory) else str(entity.category)
|
| 667 |
+
lines.append(f"│ Category: {cat_str}")
|
| 668 |
+
lines.append(f"│ Confidence: {entity.confidence:.0f}/100")
|
| 669 |
+
src_str = entity.source.value if isinstance(entity.source, ResolverSource) else str(entity.source)
|
| 670 |
+
lines.append(f"│ Source: {src_str}")
|
| 671 |
+
if entity.tags:
|
| 672 |
+
lines.append(f"│ Tags: {', '.join(entity.tags[:5])}")
|
| 673 |
+
if entity.labels:
|
| 674 |
+
for label in entity.labels:
|
| 675 |
+
lines.append(f"│ Label: [{label.source.value}] {label.label} (conf: {label.confidence:.0%})")
|
| 676 |
+
|
| 677 |
+
if entity.related_addresses:
|
| 678 |
+
lines.append(f"│ Related: {len(entity.related_addresses)} addresses")
|
| 679 |
+
if i < len(report.entities) - 1:
|
| 680 |
+
lines.append("│" + "─" * 60 + "│")
|
| 681 |
+
|
| 682 |
+
# Summary footer
|
| 683 |
+
lines.append("├────────────────────────────────────────────────────────────┤")
|
| 684 |
+
s = report.summary
|
| 685 |
+
lines.append(f"│ Summary: {s.get('total', 0)} total, {s.get('known', 0)} known, {s.get('unknown', 0)} unknown")
|
| 686 |
+
lines.append(f"│ Avg Confidence: {s.get('avg_confidence', 0):.1f}/100")
|
| 687 |
+
cats = s.get("categories", {})
|
| 688 |
+
if cats:
|
| 689 |
+
cat_str = ", ".join(f"{k}={v}" for k, v in sorted(cats.items()))
|
| 690 |
+
lines.append(f"│ Categories: {cat_str}")
|
| 691 |
+
lines.append("└────────────────────────────────────────────────────────────┘")
|
| 692 |
+
|
| 693 |
+
return "\n".join(lines)
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
# ── CLI Entry Point ────────────────────────────────────────────────────────────
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
async def main():
|
| 700 |
+
"""CLI entry point for entity resolution."""
|
| 701 |
+
import argparse
|
| 702 |
+
|
| 703 |
+
parser = argparse.ArgumentParser(
|
| 704 |
+
description="Arkham Entity Resolver — map addresses to real-world entities",
|
| 705 |
+
)
|
| 706 |
+
parser.add_argument("addresses", nargs="+", help="Blockchain address(es) to resolve")
|
| 707 |
+
parser.add_argument("--chain", "-c", default=None, help="Chain override (auto-detect if omitted)")
|
| 708 |
+
parser.add_argument("--no-arkham", action="store_true", help="Skip Arkham API lookup")
|
| 709 |
+
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
| 710 |
+
args = parser.parse_args()
|
| 711 |
+
|
| 712 |
+
report = await analyze_addresses(
|
| 713 |
+
addresses=args.addresses,
|
| 714 |
+
chain=args.chain,
|
| 715 |
+
use_arkham=not args.no_arkham,
|
| 716 |
+
)
|
| 717 |
+
|
| 718 |
+
if args.json:
|
| 719 |
+
import json
|
| 720 |
+
print(json.dumps(report.to_dict(), indent=2))
|
| 721 |
+
else:
|
| 722 |
+
print(format_report(report))
|
| 723 |
+
|
| 724 |
+
return 0 if not report.error else 1
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
if __name__ == "__main__":
|
| 728 |
+
exit(asyncio.run(main()))
|
backend/app/test_arkham_entity.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for Arkham Entity Resolver (arkham_entity.py)
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import unittest
|
| 8 |
+
from datetime import UTC, datetime
|
| 9 |
+
|
| 10 |
+
from app.arkham_entity import (
|
| 11 |
+
ArkhamEntityResolver,
|
| 12 |
+
EntityCategory,
|
| 13 |
+
EntityLabel,
|
| 14 |
+
EntityReport,
|
| 15 |
+
ResolvedEntity,
|
| 16 |
+
ResolverSource,
|
| 17 |
+
analyze_addresses,
|
| 18 |
+
format_report,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TestEntityModels(unittest.TestCase):
|
| 23 |
+
"""Test dataclass construction and serialization."""
|
| 24 |
+
|
| 25 |
+
def test_entity_label_defaults(self):
|
| 26 |
+
"""EntityLabel should accept all fields."""
|
| 27 |
+
label = EntityLabel(
|
| 28 |
+
label="Binance Hot Wallet",
|
| 29 |
+
source=ResolverSource.LOCAL_DB,
|
| 30 |
+
confidence=0.95,
|
| 31 |
+
category="exchange",
|
| 32 |
+
)
|
| 33 |
+
self.assertEqual(label.label, "Binance Hot Wallet")
|
| 34 |
+
self.assertEqual(label.source, ResolverSource.LOCAL_DB)
|
| 35 |
+
self.assertEqual(label.confidence, 0.95)
|
| 36 |
+
self.assertEqual(label.category, "exchange")
|
| 37 |
+
|
| 38 |
+
def test_resolved_entity_defaults(self):
|
| 39 |
+
"""ResolvedEntity should set default resolved_at."""
|
| 40 |
+
entity = ResolvedEntity(
|
| 41 |
+
address="0xBE0eB53FC46b790099138e3d32C721856d41e865",
|
| 42 |
+
chain="ethereum",
|
| 43 |
+
entity_name="Binance",
|
| 44 |
+
category=EntityCategory.EXCHANGE,
|
| 45 |
+
confidence=95.0,
|
| 46 |
+
)
|
| 47 |
+
self.assertEqual(entity.entity_name, "Binance")
|
| 48 |
+
self.assertEqual(entity.category, EntityCategory.EXCHANGE)
|
| 49 |
+
self.assertEqual(entity.confidence, 95.0)
|
| 50 |
+
self.assertIsNotNone(entity.resolved_at)
|
| 51 |
+
self.assertEqual(len(entity.labels), 0)
|
| 52 |
+
self.assertEqual(len(entity.tags), 0)
|
| 53 |
+
|
| 54 |
+
def test_resolved_entity_to_dict(self):
|
| 55 |
+
"""to_dict should produce a clean serializable dict."""
|
| 56 |
+
entity = ResolvedEntity(
|
| 57 |
+
address="0xBE0eB53FC46b790099138e3d32C721856d41e865",
|
| 58 |
+
chain="ethereum",
|
| 59 |
+
entity_name="Binance",
|
| 60 |
+
category=EntityCategory.EXCHANGE,
|
| 61 |
+
confidence=95.0,
|
| 62 |
+
labels=[
|
| 63 |
+
EntityLabel(
|
| 64 |
+
label="Binance Hot Wallet 7",
|
| 65 |
+
source=ResolverSource.LOCAL_DB,
|
| 66 |
+
confidence=0.95,
|
| 67 |
+
category="exchange",
|
| 68 |
+
)
|
| 69 |
+
],
|
| 70 |
+
tags=["cex", "hot_wallet"],
|
| 71 |
+
)
|
| 72 |
+
d = entity.to_dict()
|
| 73 |
+
self.assertEqual(d["entity_name"], "Binance")
|
| 74 |
+
self.assertEqual(d["category"], "exchange")
|
| 75 |
+
self.assertEqual(d["confidence"], 95.0)
|
| 76 |
+
self.assertEqual(len(d["labels"]), 1)
|
| 77 |
+
self.assertEqual(d["labels"][0]["label"], "Binance Hot Wallet 7")
|
| 78 |
+
|
| 79 |
+
def test_entity_report_defaults(self):
|
| 80 |
+
"""EntityReport should handle empty entities."""
|
| 81 |
+
report = EntityReport(
|
| 82 |
+
query_addresses=["0x1234"],
|
| 83 |
+
chain="ethereum",
|
| 84 |
+
entities=[],
|
| 85 |
+
summary={"total": 0, "known": 0, "unknown": 0, "avg_confidence": 0.0},
|
| 86 |
+
)
|
| 87 |
+
self.assertEqual(len(report.entities), 0)
|
| 88 |
+
self.assertIsNone(report.error)
|
| 89 |
+
|
| 90 |
+
def test_entity_report_error(self):
|
| 91 |
+
"""EntityReport should carry error state."""
|
| 92 |
+
report = EntityReport(
|
| 93 |
+
query_addresses=[],
|
| 94 |
+
chain="unknown",
|
| 95 |
+
entities=[],
|
| 96 |
+
summary={"total": 0},
|
| 97 |
+
error="No addresses provided",
|
| 98 |
+
)
|
| 99 |
+
self.assertEqual(report.error, "No addresses provided")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class TestEntityCategory(unittest.TestCase):
|
| 103 |
+
"""Test EntityCategory enum."""
|
| 104 |
+
|
| 105 |
+
def test_all_categories_exist(self):
|
| 106 |
+
"""All expected entity categories should be defined."""
|
| 107 |
+
expected = [
|
| 108 |
+
"exchange", "defi", "token", "bridge", "fund",
|
| 109 |
+
"whale", "nft", "gaming", "scam", "sanctioned", "unknown",
|
| 110 |
+
]
|
| 111 |
+
for cat in expected:
|
| 112 |
+
self.assertIn(cat, EntityCategory._value2member_map_)
|
| 113 |
+
|
| 114 |
+
def test_category_values(self):
|
| 115 |
+
"""Each category should map to its string value."""
|
| 116 |
+
self.assertEqual(EntityCategory.EXCHANGE.value, "exchange")
|
| 117 |
+
self.assertEqual(EntityCategory.DEFI.value, "defi")
|
| 118 |
+
self.assertEqual(EntityCategory.UNKNOWN.value, "unknown")
|
| 119 |
+
self.assertEqual(EntityCategory.SCAM.value, "scam")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class TestResolverSource(unittest.TestCase):
|
| 123 |
+
"""Test ResolverSource enum."""
|
| 124 |
+
|
| 125 |
+
def test_all_sources_exist(self):
|
| 126 |
+
"""All expected resolver sources should be defined."""
|
| 127 |
+
expected = ["arkham_api", "local_db", "entity_registry", "entity_labeler", "heuristic"]
|
| 128 |
+
for src in expected:
|
| 129 |
+
self.assertIn(src, ResolverSource._value2member_map_)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class TestArkhamEntityResolver(unittest.TestCase):
|
| 133 |
+
"""Test suite for ArkhamEntityResolver."""
|
| 134 |
+
|
| 135 |
+
def setUp(self):
|
| 136 |
+
self.resolver = ArkhamEntityResolver()
|
| 137 |
+
|
| 138 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 139 |
+
# Smoke Tests
|
| 140 |
+
# ═══════════��═══════════════════════════════════════════════════════
|
| 141 |
+
|
| 142 |
+
def test_invalid_address_returns_low_confidence(self):
|
| 143 |
+
"""Invalid address should return 0 confidence with 'Invalid Address'."""
|
| 144 |
+
entity = asyncio.run(self.resolver.resolve("not_an_address"))
|
| 145 |
+
self.assertEqual(entity.confidence, 0.0)
|
| 146 |
+
self.assertEqual(entity.entity_name, "Invalid Address")
|
| 147 |
+
self.assertEqual(entity.category, EntityCategory.UNKNOWN)
|
| 148 |
+
|
| 149 |
+
def test_empty_address_returns_low_confidence(self):
|
| 150 |
+
"""Empty string should be treated as invalid."""
|
| 151 |
+
entity = asyncio.run(self.resolver.resolve(""))
|
| 152 |
+
self.assertEqual(entity.confidence, 0.0)
|
| 153 |
+
|
| 154 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 155 |
+
# Local DB Lookup Tests
|
| 156 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 157 |
+
|
| 158 |
+
def test_known_binance_address_resolved(self):
|
| 159 |
+
"""Binance hot wallet should be resolved from local DB."""
|
| 160 |
+
entity = asyncio.run(
|
| 161 |
+
self.resolver.resolve(
|
| 162 |
+
"0xBE0eB53FC46b790099138e3d32C721856d41e865"
|
| 163 |
+
)
|
| 164 |
+
)
|
| 165 |
+
self.assertEqual(entity.entity_name, "Binance")
|
| 166 |
+
self.assertEqual(entity.category, EntityCategory.EXCHANGE)
|
| 167 |
+
self.assertGreaterEqual(entity.confidence, 90.0)
|
| 168 |
+
self.assertIn("cex", entity.tags)
|
| 169 |
+
self.assertIn("hot_wallet", entity.tags)
|
| 170 |
+
self.assertEqual(entity.source, ResolverSource.LOCAL_DB)
|
| 171 |
+
|
| 172 |
+
def test_known_coinbase_address_resolved(self):
|
| 173 |
+
"""Coinbase hot wallet should be resolved from local DB."""
|
| 174 |
+
entity = asyncio.run(
|
| 175 |
+
self.resolver.resolve(
|
| 176 |
+
"0x503828976D22510aad0201ac7EC88293211D23Da"
|
| 177 |
+
)
|
| 178 |
+
)
|
| 179 |
+
self.assertEqual(entity.entity_name, "Coinbase")
|
| 180 |
+
self.assertEqual(entity.category, EntityCategory.EXCHANGE)
|
| 181 |
+
|
| 182 |
+
def test_known_uniswap_router_resolved(self):
|
| 183 |
+
"""Uniswap V2 Router should be resolved from local DB."""
|
| 184 |
+
entity = asyncio.run(
|
| 185 |
+
self.resolver.resolve(
|
| 186 |
+
"0x7a250d5630b4cf139281983dce37532e7d5c9196"
|
| 187 |
+
)
|
| 188 |
+
)
|
| 189 |
+
self.assertEqual(entity.entity_name, "Uniswap V2 Router")
|
| 190 |
+
self.assertEqual(entity.category, EntityCategory.DEFI)
|
| 191 |
+
|
| 192 |
+
def test_known_tether_contract_resolved(self):
|
| 193 |
+
"""Tether USDT contract should be resolved from local DB."""
|
| 194 |
+
entity = asyncio.run(
|
| 195 |
+
self.resolver.resolve(
|
| 196 |
+
"0xdAC17F958D2ee523a2206206994597C13D831ec7"
|
| 197 |
+
)
|
| 198 |
+
)
|
| 199 |
+
self.assertEqual(entity.entity_name, "Tether (USDT)")
|
| 200 |
+
self.assertEqual(entity.category, EntityCategory.TOKEN)
|
| 201 |
+
|
| 202 |
+
def test_lazarus_group_resolved(self):
|
| 203 |
+
"""Sanctioned entity should be resolved from local DB."""
|
| 204 |
+
entity = asyncio.run(
|
| 205 |
+
self.resolver.resolve(
|
| 206 |
+
"0x1CBd3b2770909D4e10f157cABC84C7264073C9Ec"
|
| 207 |
+
)
|
| 208 |
+
)
|
| 209 |
+
self.assertIn("Lazarus", entity.entity_name)
|
| 210 |
+
self.assertIn("sanctioned", entity.tags)
|
| 211 |
+
|
| 212 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 213 |
+
# Unknown Address Tests
|
| 214 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 215 |
+
|
| 216 |
+
def test_unknown_address_returns_unknown(self):
|
| 217 |
+
"""Unknown address should return 'Unknown' with low confidence."""
|
| 218 |
+
entity = asyncio.run(
|
| 219 |
+
self.resolver.resolve(
|
| 220 |
+
"0x1234567890abcdef1234567890abcdef12345678",
|
| 221 |
+
use_arkham=False,
|
| 222 |
+
)
|
| 223 |
+
)
|
| 224 |
+
# Should be unknown since it's not in any database
|
| 225 |
+
self.assertEqual(entity.entity_name, "Unknown")
|
| 226 |
+
self.assertEqual(entity.category, EntityCategory.UNKNOWN)
|
| 227 |
+
self.assertEqual(entity.confidence, 0.0)
|
| 228 |
+
|
| 229 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 230 |
+
# Chain Detection Tests
|
| 231 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 232 |
+
|
| 233 |
+
def test_ethereum_address_detected(self):
|
| 234 |
+
"""0x-prefixed 40-hex-char addresses should detect as ethereum."""
|
| 235 |
+
entity = asyncio.run(
|
| 236 |
+
self.resolver.resolve(
|
| 237 |
+
"0xBE0eB53FC46b790099138e3d32C721856d41e865"
|
| 238 |
+
)
|
| 239 |
+
)
|
| 240 |
+
self.assertEqual(entity.chain, "ethereum")
|
| 241 |
+
|
| 242 |
+
def test_solana_address_detected(self):
|
| 243 |
+
"""Base58 addresses should detect as solana."""
|
| 244 |
+
entity = asyncio.run(
|
| 245 |
+
self.resolver.resolve(
|
| 246 |
+
"9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"
|
| 247 |
+
)
|
| 248 |
+
)
|
| 249 |
+
self.assertEqual(entity.chain, "solana")
|
| 250 |
+
|
| 251 |
+
def test_explicit_chain_override(self):
|
| 252 |
+
"""Explicit chain parameter should override detection."""
|
| 253 |
+
entity = asyncio.run(
|
| 254 |
+
self.resolver.resolve(
|
| 255 |
+
"0xBE0eB53FC46b790099138e3d32C721856d41e865",
|
| 256 |
+
chain="polygon",
|
| 257 |
+
)
|
| 258 |
+
)
|
| 259 |
+
self.assertEqual(entity.chain, "polygon")
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
class TestAnalyzeAddresses(unittest.TestCase):
|
| 263 |
+
"""Test the high-level analyze_addresses function."""
|
| 264 |
+
|
| 265 |
+
def test_empty_addresses_returns_error(self):
|
| 266 |
+
"""Empty address list should return an error report."""
|
| 267 |
+
report = asyncio.run(analyze_addresses([]))
|
| 268 |
+
self.assertIsNotNone(report.error)
|
| 269 |
+
self.assertEqual(report.error, "No addresses provided")
|
| 270 |
+
|
| 271 |
+
def test_single_known_address(self):
|
| 272 |
+
"""Single known address should produce a correct report."""
|
| 273 |
+
report = asyncio.run(
|
| 274 |
+
analyze_addresses(
|
| 275 |
+
["0xBE0eB53FC46b790099138e3d32C721856d41e865"],
|
| 276 |
+
use_arkham=False,
|
| 277 |
+
)
|
| 278 |
+
)
|
| 279 |
+
self.assertEqual(len(report.entities), 1)
|
| 280 |
+
self.assertEqual(report.entities[0].entity_name, "Binance")
|
| 281 |
+
self.assertIsNone(report.error)
|
| 282 |
+
self.assertEqual(report.summary["total"], 1)
|
| 283 |
+
self.assertEqual(report.summary["known"], 1)
|
| 284 |
+
|
| 285 |
+
def test_multiple_addresses_mixed(self):
|
| 286 |
+
"""Mixed known/unknown addresses should both appear."""
|
| 287 |
+
report = asyncio.run(
|
| 288 |
+
analyze_addresses(
|
| 289 |
+
[
|
| 290 |
+
"0xBE0eB53FC46b790099138e3d32C721856d41e865", # Binance
|
| 291 |
+
"0x0000000000000000000000000000000000000001", # Unknown
|
| 292 |
+
],
|
| 293 |
+
use_arkham=False,
|
| 294 |
+
)
|
| 295 |
+
)
|
| 296 |
+
self.assertEqual(len(report.entities), 2)
|
| 297 |
+
self.assertEqual(report.entities[0].entity_name, "Binance")
|
| 298 |
+
self.assertEqual(report.entities[1].entity_name, "Unknown")
|
| 299 |
+
|
| 300 |
+
def test_report_summary(self):
|
| 301 |
+
"""Report summary should have correct aggregation."""
|
| 302 |
+
report = asyncio.run(
|
| 303 |
+
analyze_addresses(
|
| 304 |
+
[
|
| 305 |
+
"0xBE0eB53FC46b790099138e3d32C721856d41e865", # Binance
|
| 306 |
+
"0x503828976D22510aad0201ac7EC88293211D23Da", # Coinbase
|
| 307 |
+
],
|
| 308 |
+
use_arkham=False,
|
| 309 |
+
)
|
| 310 |
+
)
|
| 311 |
+
self.assertEqual(report.summary["total"], 2)
|
| 312 |
+
self.assertEqual(report.summary["known"], 2)
|
| 313 |
+
self.assertEqual(report.summary["unknown"], 0)
|
| 314 |
+
self.assertGreater(report.summary["avg_confidence"], 90.0)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
class TestFormatReport(unittest.TestCase):
|
| 318 |
+
"""Test the format_report output."""
|
| 319 |
+
|
| 320 |
+
def test_format_report_contains_entity_names(self):
|
| 321 |
+
"""Formatted report should include entity names."""
|
| 322 |
+
report = asyncio.run(
|
| 323 |
+
analyze_addresses(
|
| 324 |
+
["0xBE0eB53FC46b790099138e3d32C721856d41e865"],
|
| 325 |
+
use_arkham=False,
|
| 326 |
+
)
|
| 327 |
+
)
|
| 328 |
+
output = format_report(report)
|
| 329 |
+
self.assertIn("Binance", output)
|
| 330 |
+
self.assertIn("exchange", output)
|
| 331 |
+
self.assertIn("confidence", output.lower())
|
| 332 |
+
|
| 333 |
+
def test_format_report_error(self):
|
| 334 |
+
"""Error report should include the error message."""
|
| 335 |
+
report = EntityReport(
|
| 336 |
+
query_addresses=[],
|
| 337 |
+
chain="unknown",
|
| 338 |
+
entities=[],
|
| 339 |
+
summary={"total": 0},
|
| 340 |
+
error="No addresses provided",
|
| 341 |
+
)
|
| 342 |
+
output = format_report(report)
|
| 343 |
+
self.assertIn("ERROR", output)
|
| 344 |
+
self.assertIn("No addresses provided", output)
|
| 345 |
+
|
| 346 |
+
def test_format_report_multiple_entities(self):
|
| 347 |
+
"""Report with multiple entities should show all."""
|
| 348 |
+
report = asyncio.run(
|
| 349 |
+
analyze_addresses(
|
| 350 |
+
[
|
| 351 |
+
"0xBE0eB53FC46b790099138e3d32C721856d41e865",
|
| 352 |
+
"0x503828976D22510aad0201ac7EC88293211D23Da",
|
| 353 |
+
],
|
| 354 |
+
use_arkham=False,
|
| 355 |
+
)
|
| 356 |
+
)
|
| 357 |
+
output = format_report(report)
|
| 358 |
+
self.assertIn("Binance", output)
|
| 359 |
+
self.assertIn("Coinbase", output)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
class TestBatchResolve(unittest.TestCase):
|
| 363 |
+
"""Test the batch resolve functionality."""
|
| 364 |
+
|
| 365 |
+
def test_batch_resolve_empty(self):
|
| 366 |
+
"""Empty batch should return empty list."""
|
| 367 |
+
entities = asyncio.run(self._empty_batch())
|
| 368 |
+
self.assertEqual(len(entities), 0)
|
| 369 |
+
|
| 370 |
+
def _empty_batch(self):
|
| 371 |
+
r = ArkhamEntityResolver()
|
| 372 |
+
return r.resolve_batch([], use_arkham=False)
|
| 373 |
+
|
| 374 |
+
def test_batch_resolve_multiple(self):
|
| 375 |
+
"""Batch resolve should return results for all addresses."""
|
| 376 |
+
async def _run():
|
| 377 |
+
r = ArkhamEntityResolver()
|
| 378 |
+
results = await r.resolve_batch(
|
| 379 |
+
[
|
| 380 |
+
"0xBE0eB53FC46b790099138e3d32C721856d41e865",
|
| 381 |
+
"invalid_address",
|
| 382 |
+
],
|
| 383 |
+
use_arkham=False,
|
| 384 |
+
)
|
| 385 |
+
await r.close()
|
| 386 |
+
return results
|
| 387 |
+
|
| 388 |
+
entities = asyncio.run(_run())
|
| 389 |
+
self.assertEqual(len(entities), 2)
|
| 390 |
+
self.assertEqual(entities[0].entity_name, "Binance")
|
| 391 |
+
self.assertEqual(entities[1].entity_name, "Invalid Address")
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
if __name__ == "__main__":
|
| 395 |
+
unittest.main()
|
backend/scripts/minimax_review_arkham_entity.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
MiniMax Review: arkham_entity.py
|
| 4 |
+
|
| 5 |
+
Reads app/arkham_entity.py and sends it to MiniMax-Text-01 for code review.
|
| 6 |
+
Usage: python3 scripts/minimax_review_arkham_entity.py
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
import urllib.request
|
| 13 |
+
|
| 14 |
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 15 |
+
BACKEND_DIR = os.path.dirname(SCRIPT_DIR)
|
| 16 |
+
sys.path.insert(0, BACKEND_DIR)
|
| 17 |
+
|
| 18 |
+
# Read the source file
|
| 19 |
+
source_path = os.path.join(BACKEND_DIR, "app", "arkham_entity.py")
|
| 20 |
+
with open(source_path) as f:
|
| 21 |
+
code = f.read()[:5000] # first 5000 chars
|
| 22 |
+
|
| 23 |
+
# Get MiniMax API key
|
| 24 |
+
key = ""
|
| 25 |
+
env_path = os.path.expanduser("~/.hermes/.env")
|
| 26 |
+
if os.path.exists(env_path):
|
| 27 |
+
with open(env_path) as f:
|
| 28 |
+
for line in f:
|
| 29 |
+
if line.startswith("MINIMAX_API_KEY="):
|
| 30 |
+
key = line.split("=", 1)[1].strip().strip("'\"")
|
| 31 |
+
break
|
| 32 |
+
if not key:
|
| 33 |
+
key = os.environ.get("MINIMAX_API_KEY", "")
|
| 34 |
+
if not key:
|
| 35 |
+
# Fallback to /app/.env (docker)
|
| 36 |
+
docker_env = "/app/.env"
|
| 37 |
+
if os.path.exists(docker_env):
|
| 38 |
+
with open(docker_env) as f:
|
| 39 |
+
for line in f:
|
| 40 |
+
if line.startswith("MINIMAX_API_KEY="):
|
| 41 |
+
key = line.split("=", 1)[1].strip().strip("'\"")
|
| 42 |
+
break
|
| 43 |
+
if not key:
|
| 44 |
+
print("ERROR: No MiniMax API key found")
|
| 45 |
+
sys.exit(1)
|
| 46 |
+
|
| 47 |
+
body = json.dumps({
|
| 48 |
+
"model": "MiniMax-Text-01",
|
| 49 |
+
"messages": [
|
| 50 |
+
{
|
| 51 |
+
"role": "system",
|
| 52 |
+
"content": (
|
| 53 |
+
"You are a senior Python code reviewer. Review the following code for "
|
| 54 |
+
"bugs, security issues, anti-patterns, and improvements. "
|
| 55 |
+
"List the top 3 most important issues found, with line numbers and "
|
| 56 |
+
"suggested fixes. Be concise and specific."
|
| 57 |
+
),
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"role": "user",
|
| 61 |
+
"content": code,
|
| 62 |
+
},
|
| 63 |
+
],
|
| 64 |
+
"max_tokens": 600,
|
| 65 |
+
}).encode()
|
| 66 |
+
|
| 67 |
+
req = urllib.request.Request(
|
| 68 |
+
"https://api.minimax.io/v1/chat/completions",
|
| 69 |
+
data=body,
|
| 70 |
+
headers={
|
| 71 |
+
"Authorization": f"Bearer {key}",
|
| 72 |
+
"Content-Type": "application/json",
|
| 73 |
+
},
|
| 74 |
+
)
|
| 75 |
+
resp = urllib.request.urlopen(req, timeout=30)
|
| 76 |
+
result = json.loads(resp.read())
|
| 77 |
+
review = result["choices"][0]["message"]["content"]
|
| 78 |
+
print(review)
|