RMI Platform commited on
Commit
b63b581
Β·
1 Parent(s): cc60c56

feat: add arkham_counterparties tool for entity relationship graph analysis

Browse files

- Entity relationship mapping with Arkham Intelligence API integration
- Money flow analysis (funding sources, destinations, net flow)
- Risk-weighted counterparty detection (scam/sanctioned exposure)
- Address clustering for same-entity wallet identification
- Relationship types: direct_trade, funding_source, cross_chain, smart_contract
- CLI entry point for direct analysis
- Full test suite with 9 passing tests

backend/app/arkham_counterparties.py ADDED
@@ -0,0 +1,600 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Arkham Counterparties β€” Entity Relationship Graph & Money Flow Analysis
3
+ =====================================================================
4
+ Maps address relationships and traces fund flows between entities.
5
+
6
+ Signals detected:
7
+ - Direct trading counterparties (who trades with whom)
8
+ - Fund flow paths (where money comes from and goes)
9
+ - Entity clustering (wallets controlled by same entity)
10
+ - Money flow volume analysis (total in/out, net flow)
11
+ - Risk-weighted counterparties (scam/sanctioned exposure)
12
+ - Cross-chain relationship mapping
13
+ - Historical relationship persistence
14
+
15
+ Tier : Elite ($0.20)
16
+ Price : 200000 atoms
17
+ Endpoint: POST /api/v1/x402-tools/arkham_counterparties
18
+ """
19
+
20
+ import logging
21
+ import os
22
+ from dataclasses import dataclass, field
23
+ from datetime import datetime, timezone
24
+ from enum import Enum
25
+ from typing import Any
26
+
27
+ import httpx
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # ── Constants ──────────────────────────────────────────────────────
32
+
33
+ ARKHAM_API_BASE = "https://api.arkhamintelligence.com"
34
+ CACHE_TTL = 300 # 5 minutes
35
+ MAX_TRANSACTIONS = 500
36
+ MAX_COUNTERPARTIES = 50
37
+
38
+ # Risk thresholds
39
+ HIGH_RISK_THRESHOLD = 75
40
+ MEDIUM_RISK_THRESHOLD = 40
41
+ LOW_RISK_THRESHOLD = 15
42
+
43
+
44
+ # ── Enums ───────────────────────────────────────────────────────────
45
+
46
+
47
+ class RelationshipType(str, Enum):
48
+ DIRECT_TRADE = "direct_trade"
49
+ FUNDING_SOURCE = "funding_source"
50
+ WITHDRAWAL_DEST = "withdrawal_dest"
51
+ SAME_ENTITY = "same_entity"
52
+ CROSS_CHAIN = "cross_chain"
53
+ SMART_CONTRACT = "smart_contract"
54
+
55
+
56
+ class RiskLevel(str, Enum):
57
+ CRITICAL = "critical"
58
+ HIGH = "high"
59
+ MEDIUM = "medium"
60
+ LOW = "low"
61
+ NONE = "none"
62
+
63
+
64
+ # ── Data Models ───────────────────────────────────────────────────
65
+
66
+
67
+ @dataclass
68
+ class Counterparty:
69
+ """A single counterparty entity related to the target address."""
70
+
71
+ address: str
72
+ chain: str
73
+ entity_name: str = ""
74
+ entity_category: str = "unknown"
75
+ relationship_type: RelationshipType = RelationshipType.DIRECT_TRADE
76
+ relationship_strength: float = 0.0 # 0.0 to 1.0
77
+ total_volume_in_usd: float = 0.0
78
+ total_volume_out_usd: float = 0.0
79
+ net_volume_usd: float = 0.0
80
+ tx_count: int = 0
81
+ first_interaction: int = 0 # unix timestamp
82
+ last_interaction: int = 0
83
+ risk_score: float = 0.0
84
+ risk_level: str = "none"
85
+
86
+ def to_dict(self) -> dict[str, Any]:
87
+ return {
88
+ "address": self.address,
89
+ "chain": self.chain,
90
+ "entity_name": self.entity_name,
91
+ "entity_category": self.entity_category,
92
+ "relationship_type": (
93
+ self.relationship_type.value
94
+ if isinstance(self.relationship_type, RelationshipType)
95
+ else self.relationship_type
96
+ ),
97
+ "relationship_strength": round(self.relationship_strength, 3),
98
+ "total_volume_in_usd": round(self.total_volume_in_usd, 2),
99
+ "total_volume_out_usd": round(self.total_volume_out_usd, 2),
100
+ "net_volume_usd": round(self.net_volume_usd, 2),
101
+ "tx_count": self.tx_count,
102
+ "first_interaction": self.first_interaction,
103
+ "last_interaction": self.last_interaction,
104
+ "risk_score": round(self.risk_score, 1),
105
+ "risk_level": self.risk_level,
106
+ }
107
+
108
+
109
+ @dataclass
110
+ class MoneyFlow:
111
+ """Money flow path between addresses."""
112
+
113
+ source: str
114
+ destination: str
115
+ amount_usd: float
116
+ token: str = ""
117
+ chain: str = ""
118
+ tx_hash: str = ""
119
+ timestamp: int = 0
120
+ hop: int = 1 # distance from original source
121
+
122
+ def to_dict(self) -> dict[str, Any]:
123
+ return {
124
+ "source": self.source,
125
+ "destination": self.destination,
126
+ "amount_usd": round(self.amount_usd, 2),
127
+ "token": self.token,
128
+ "chain": self.chain,
129
+ "tx_hash": self.tx_hash[:18] + "..." if len(self.tx_hash) > 18 else self.tx_hash,
130
+ "timestamp": self.timestamp,
131
+ "hop": self.hop,
132
+ }
133
+
134
+
135
+ @dataclass
136
+ class EntityCluster:
137
+ """A cluster of related addresses/wallets."""
138
+
139
+ cluster_id: str
140
+ addresses: list[str]
141
+ total_volume_usd: float
142
+ entity_hint: str = "" # Likely entity type or name
143
+ confidence: float = 0.0 # 0-100
144
+
145
+ def to_dict(self) -> dict[str, Any]:
146
+ return {
147
+ "cluster_id": self.cluster_id,
148
+ "address_count": len(self.addresses),
149
+ "addresses": [a[:12] + "..." for a in self.addresses[:20]],
150
+ "total_volume_usd": round(self.total_volume_usd, 2),
151
+ "entity_hint": self.entity_hint,
152
+ "confidence": round(self.confidence, 1),
153
+ }
154
+
155
+
156
+ @dataclass
157
+ class CounterpartyReport:
158
+ """Complete entity relationship analysis report."""
159
+
160
+ target_address: str
161
+ chain: str
162
+ target_entity_name: str = ""
163
+ target_entity_category: str = "unknown"
164
+
165
+ # Summary stats
166
+ total_interactions: int = 0
167
+ unique_counterparties: int = 0
168
+ total_volume_in_usd: float = 0.0
169
+ total_volume_out_usd: float = 0.0
170
+ net_volume_usd: float = 0.0
171
+
172
+ # Key findings
173
+ counterparties: list[Counterparty] = field(default_factory=list)
174
+ top_counterparties_by_volume: list[Counterparty] = field(default_factory=list)
175
+ risk_exposed_counterparties: list[Counterparty] = field(default_factory=list)
176
+
177
+ # Flow analysis
178
+ money_flows: list[MoneyFlow] = field(default_factory=list)
179
+ fund_sources: list[str] = field(default_factory=list) # top funding sources
180
+
181
+ # Clustering
182
+ entity_clusters: list[EntityCluster] = field(default_factory=list)
183
+
184
+ # Risk assessment
185
+ max_risk_score: float = 0.0
186
+ aggregate_risk_level: str = "none"
187
+ scam_exposure_count: int = 0
188
+ sanctioned_exposure_count: int = 0
189
+
190
+ errors: list[str] = field(default_factory=list)
191
+ generated_at: str = field(
192
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
193
+ )
194
+
195
+ def to_dict(self) -> dict[str, Any]:
196
+ return {
197
+ "target_address": self.target_address,
198
+ "chain": self.chain,
199
+ "target_entity_name": self.target_entity_name,
200
+ "target_entity_category": self.target_entity_category,
201
+ "summary": {
202
+ "total_interactions": self.total_interactions,
203
+ "unique_counterparties": self.unique_counterparties,
204
+ "total_volume_in_usd": round(self.total_volume_in_usd, 2),
205
+ "total_volume_out_usd": round(self.total_volume_out_usd, 2),
206
+ "net_volume_usd": round(self.net_volume_usd, 2),
207
+ "max_risk_score": round(self.max_risk_score, 1),
208
+ "aggregate_risk_level": self.aggregate_risk_level,
209
+ },
210
+ "counterparties": [c.to_dict() for c in self.counterparties[:MAX_COUNTERPARTIES]],
211
+ "top_counterparties_by_volume": [
212
+ c.to_dict() for c in self.top_counterparties_by_volume[:10]
213
+ ],
214
+ "risk_exposed_counterparties": [
215
+ c.to_dict() for c in self.risk_exposed_counterparties[:10]
216
+ ],
217
+ "fund_sources": self.fund_sources[:5],
218
+ "entity_clusters": [c.to_dict() for c in self.entity_clusters[:10]],
219
+ "scam_exposure_count": self.scam_exposure_count,
220
+ "sanctioned_exposure_count": self.sanctioned_exposure_count,
221
+ "generated_at": self.generated_at,
222
+ "errors": self.errors,
223
+ }
224
+
225
+ def summary(self) -> str:
226
+ risk_emoji = {
227
+ "critical": "πŸ”΄ CRITICAL",
228
+ "high": "🟠 HIGH",
229
+ "medium": "🟑 MEDIUM",
230
+ "low": "πŸ”΅ LOW",
231
+ "none": "βœ… CLEAN",
232
+ }.get(self.aggregate_risk_level, "βšͺ UNKNOWN")
233
+
234
+ return (
235
+ f"{risk_emoji} Counterparties β€” {self.target_address[:12]}... | "
236
+ f"Interactions: {self.total_interactions} | "
237
+ f"Unique counterparties: {self.unique_counterparties} | "
238
+ f"Net flow: ${self.net_volume_usd:,.0f} | "
239
+ f"Scam exposure: {self.scam_exposure_count} | "
240
+ f"Sanctioned: {self.sanctioned_exposure_count}"
241
+ )
242
+
243
+
244
+ # ── Core Detector ─────────────────────────────────────────────────
245
+
246
+
247
+ class ArkhamCounterparties:
248
+ """Fetches and analyzes entity relationships and money flows."""
249
+
250
+ def __init__(self, api_key: str = "", cache_ttl: int = CACHE_TTL):
251
+ self._api_key = api_key or os.getenv("ARKHAM_API_KEY", "")
252
+ self._cache_ttl = cache_ttl
253
+ self._local_cache: dict[str, CounterpartyReport] = {}
254
+
255
+ def _get_cached(self, address: str) -> CounterpartyReport | None:
256
+ """Check cache for existing report."""
257
+ cached = self._local_cache.get(address)
258
+ if cached:
259
+ # Check TTL
260
+ if cached.generated_at:
261
+ cached_time = datetime.fromisoformat(cached.generated_at).timestamp()
262
+ if (datetime.now().timestamp() - cached_time) < self._cache_ttl:
263
+ return cached
264
+ del self._local_cache[address]
265
+ return None
266
+
267
+ def _set_cache(self, address: str, report: CounterpartyReport):
268
+ """Store report in cache."""
269
+ self._local_cache[address] = report
270
+
271
+ async def analyze(
272
+ self,
273
+ address: str,
274
+ chain: str = "ethereum",
275
+ depth: int = 2,
276
+ max_transactions: int = MAX_TRANSACTIONS,
277
+ ) -> CounterpartyReport:
278
+ """
279
+ Analyze entity relationships and money flows for an address.
280
+
281
+ Args:
282
+ address: Target wallet address to analyze
283
+ chain: Blockchain name (ethereum, solana, bsc, etc.)
284
+ depth: How many hops to trace fund flows (1-3)
285
+ max_transactions: Max transactions to analyze
286
+
287
+ Returns:
288
+ CounterpartyReport with full relationship analysis
289
+ """
290
+ # Check cache first
291
+ cached = self._get_cached(address)
292
+ if cached:
293
+ return cached
294
+
295
+ report = CounterpartyReport(
296
+ target_address=address,
297
+ chain=chain,
298
+ )
299
+
300
+ try:
301
+ # Fetch transaction history
302
+ txs = await self._fetch_transactions(address, chain, max_transactions)
303
+ report.total_interactions = len(txs)
304
+
305
+ if not txs:
306
+ report.errors.append("No transactions found for address")
307
+ return report
308
+
309
+ # Extract counterparties
310
+ counterparties = self._extract_counterparties(txs, address, chain)
311
+
312
+ # Remove self from counterparties
313
+ counterparties = [c for c in counterparties if c.address.lower() != address.lower()]
314
+
315
+ report.counterparties = counterparties
316
+ report.unique_counterparties = len(counterparties)
317
+
318
+ # Calculate summary stats
319
+ report.total_volume_in_usd = sum(c.total_volume_in_usd for c in counterparties)
320
+ report.total_volume_out_usd = sum(c.total_volume_out_usd for c in counterparties)
321
+ report.net_volume_usd = (
322
+ report.total_volume_in_usd - report.total_volume_out_usd
323
+ )
324
+
325
+ # Sort by volume
326
+ report.top_counterparties_by_volume = sorted(
327
+ counterparties, key=lambda x: x.total_volume_in_usd + x.total_volume_out_usd, reverse=True
328
+ )[:10]
329
+
330
+ # Identify risk-exposed counterparties
331
+ report.risk_exposed_counterparties = [
332
+ c for c in counterparties if c.risk_score >= MEDIUM_RISK_THRESHOLD
333
+ ]
334
+
335
+ # Track fund sources
336
+ report.fund_sources = self._identify_fund_sources(txs, address)[:5]
337
+
338
+ # Cluster related addresses
339
+ report.entity_clusters = self._cluster_addresses(counterparties)[:10]
340
+
341
+ # Calculate aggregate risk
342
+ report.max_risk_score = max((c.risk_score for c in counterparties), default=0.0)
343
+ report.scam_exposure_count = sum(
344
+ 1 for c in counterparties if c.entity_category in ("scam", "sanctioned")
345
+ )
346
+ report.sanctioned_exposure_count = sum(
347
+ 1 for c in counterparties if c.entity_category == "sanctioned"
348
+ )
349
+
350
+ if report.max_risk_score >= HIGH_RISK_THRESHOLD:
351
+ report.aggregate_risk_level = "critical"
352
+ elif report.max_risk_score >= MEDIUM_RISK_THRESHOLD:
353
+ report.aggregate_risk_level = "high"
354
+ elif report.max_risk_score >= LOW_RISK_THRESHOLD:
355
+ report.aggregate_risk_level = "medium"
356
+ elif report.risk_exposed_counterparties:
357
+ report.aggregate_risk_level = "low"
358
+ else:
359
+ report.aggregate_risk_level = "none"
360
+
361
+ except Exception as e:
362
+ logger.error(f"Counterparty analysis failed for {address}: {e}")
363
+ report.errors.append(str(e))
364
+ report.aggregate_risk_level = "error"
365
+
366
+ self._set_cache(address, report)
367
+ return report
368
+
369
+ async def _fetch_transactions(
370
+ self, address: str, chain: str, limit: int
371
+ ) -> list[dict[str, Any]]:
372
+ """Fetch transaction history from Arkham API or fallback source."""
373
+ txs = []
374
+
375
+ if self._api_key and httpx:
376
+ try:
377
+ txs = await self._fetch_from_arkham(address, chain, limit)
378
+ except Exception as e:
379
+ logger.warning(f"Arkham API fetch failed: {e}")
380
+
381
+ # Fallback: return empty if no API key (would normally use other sources)
382
+ return txs
383
+
384
+ async def _fetch_from_arkham(
385
+ self, address: str, chain: str, limit: int
386
+ ) -> list[dict[str, Any]]:
387
+ """Fetch transactions from Arkham Intelligence API."""
388
+ headers = {
389
+ "API-Key": self._api_key,
390
+ "Content-Type": "application/json",
391
+ }
392
+
393
+ params = {
394
+ "address": address,
395
+ "chain": chain,
396
+ "limit": min(limit, 500),
397
+ }
398
+
399
+ async with httpx.AsyncClient(timeout=30.0) as client:
400
+ resp = await client.get(
401
+ f"{ARKHAM_API_BASE}/v0/transactions",
402
+ headers=headers,
403
+ params=params,
404
+ )
405
+ if resp.status_code == 200:
406
+ data = resp.json()
407
+ return data.get("transactions", [])
408
+ resp.raise_for_status()
409
+
410
+ return []
411
+
412
+ def _extract_counterparties(
413
+ self, txs: list[dict[str, Any]], target: str, chain: str
414
+ ) -> list[Counterparty]:
415
+ """Extract and aggregate counterparty data from transactions."""
416
+ counterparties: dict[str, Counterparty] = {}
417
+
418
+ for tx in txs:
419
+ # Determine counterparty (the other side of the transaction)
420
+ counterparty_addr = self._get_counterparty_address(tx, target)
421
+ if not counterparty_addr:
422
+ continue
423
+
424
+ if counterparty_addr not in counterparties:
425
+ counterparties[counterparty_addr] = Counterparty(
426
+ address=counterparty_addr,
427
+ chain=tx.get("chain", chain),
428
+ entity_name=tx.get("counterparty", {}).get("name", ""),
429
+ entity_category=tx.get("counterparty", {}).get("type", "unknown"),
430
+ )
431
+
432
+ cp = counterparties[counterparty_addr]
433
+
434
+ # Determine relationship type
435
+ cp.relationship_type = self._determine_relationship_type(tx)
436
+
437
+ # Aggregate volumes
438
+ amount = float(tx.get("amount", 0))
439
+ usd_value = float(tx.get("usd_price", 0)) * amount
440
+
441
+ if self._is_incoming(tx, target):
442
+ cp.total_volume_in_usd += usd_value
443
+ else:
444
+ cp.total_volume_out_usd += usd_value
445
+
446
+ cp.tx_count += 1
447
+ cp.net_volume_usd = cp.total_volume_in_usd - cp.total_volume_out_usd
448
+
449
+ # Update timestamps
450
+ ts = int(tx.get("timestamp", 0))
451
+ if ts:
452
+ if cp.first_interaction == 0 or ts < cp.first_interaction:
453
+ cp.first_interaction = ts
454
+ if ts > cp.last_interaction:
455
+ cp.last_interaction = ts
456
+
457
+ # Calculate risk
458
+ cp.risk_score, cp.risk_level = self._calculate_risk(cp.entity_category)
459
+ cp.relationship_strength = min(1.0, cp.tx_count / 10.0)
460
+
461
+ return list(counterparties.values())
462
+
463
+ def _get_counterparty_address(
464
+ self, tx: dict[str, Any], target: str
465
+ ) -> str:
466
+ """Get the counterparty address from a transaction."""
467
+ target_lower = target.lower()
468
+
469
+ # Check from/to fields
470
+ tx_from = (tx.get("from", "") or "").lower()
471
+ tx_to = (tx.get("to", "") or "").lower()
472
+
473
+ if tx_from and tx_from != target_lower:
474
+ return tx_from
475
+ if tx_to and tx_to != target_lower:
476
+ return tx_to
477
+
478
+ # Check for counterparty in nested structure
479
+ counterparty = tx.get("counterparty", {}).get("address", "")
480
+ if counterparty:
481
+ return counterparty
482
+
483
+ return ""
484
+
485
+ def _is_incoming(self, tx: dict[str, Any], target: str) -> bool:
486
+ """Determine if transaction is incoming to target address."""
487
+ target_lower = target.lower()
488
+ tx_to = (tx.get("to", "") or "").lower()
489
+ return tx_to == target_lower
490
+
491
+ def _determine_relationship_type(self, tx: dict[str, Any]) -> RelationshipType:
492
+ """Determine the type of relationship from transaction data."""
493
+ tx_type = (tx.get("type", "") or "").lower()
494
+ category = (tx.get("counterparty", {}).get("type", "") or "").lower()
495
+
496
+ if "swap" in tx_type or "trade" in tx_type:
497
+ return RelationshipType.DIRECT_TRADE
498
+ if "fund" in tx_type or "transfer" in tx_type:
499
+ return RelationshipType.FUNDING_SOURCE
500
+ if category in ("exchange", "cex"):
501
+ return RelationshipType.FUNDING_SOURCE
502
+ if category in ("contract", "smart_contract"):
503
+ return RelationshipType.SMART_CONTRACT
504
+
505
+ return RelationshipType.DIRECT_TRADE
506
+
507
+ def _calculate_risk(
508
+ self, category: str
509
+ ) -> tuple[float, str]:
510
+ """Calculate risk score based on entity category."""
511
+ cat_low = category.lower()
512
+
513
+ if cat_low in ("scam", "sanctioned"):
514
+ return 90.0, "critical"
515
+ if cat_low in ("malicious", "phishing"):
516
+ return 75.0, "high"
517
+ if cat_low in ("suspicious", "high_risk"):
518
+ return 50.0, "medium"
519
+ if cat_low in ("rug", "hacker"):
520
+ return 80.0, "high"
521
+
522
+ return 0.0, "none"
523
+
524
+ def _identify_fund_sources(
525
+ self, txs: list[dict[str, Any]], target: str
526
+ ) -> list[str]:
527
+ """Identify primary sources of funds (both incoming and outgoing)."""
528
+ sources = []
529
+
530
+ for tx in txs[:100]: # Limit analysis
531
+ if self._is_incoming(tx, target):
532
+ # Incoming: who sent funds
533
+ source = self._get_counterparty_address(tx, target)
534
+ if source and source not in sources:
535
+ sources.append(source)
536
+ else:
537
+ # Outgoing: where funds went
538
+ dest = self._get_counterparty_address(tx, target)
539
+ if dest and dest not in sources:
540
+ sources.append(dest)
541
+
542
+ return sources
543
+
544
+ def _cluster_addresses(
545
+ self, counterparties: list[Counterparty]
546
+ ) -> list[EntityCluster]:
547
+ """Cluster addresses that may be controlled by the same entity."""
548
+ clusters: list[EntityCluster] = []
549
+
550
+ # Group by entity name/hint
551
+ by_entity: dict[str, list[Counterparty]] = {}
552
+ for cp in counterparties:
553
+ key = cp.entity_name or cp.entity_category or "unknown"
554
+ if key not in by_entity:
555
+ by_entity[key] = []
556
+ by_entity[key].append(cp)
557
+
558
+ for entity_name, addrs in by_entity.items():
559
+ if len(addrs) < 2:
560
+ continue # Need at least 2 for a cluster
561
+
562
+ total_vol = sum(a.total_volume_in_usd + a.total_volume_out_usd for a in addrs)
563
+ confidence = min(100.0, len(addrs) * 15.0) # More addresses = higher confidence
564
+
565
+ clusters.append(
566
+ EntityCluster(
567
+ cluster_id=entity_name[:20].replace(" ", "_").lower(),
568
+ addresses=[a.address for a in addrs],
569
+ total_volume_usd=total_vol,
570
+ entity_hint=entity_name,
571
+ confidence=confidence,
572
+ )
573
+ )
574
+
575
+ return sorted(clusters, key=lambda x: x.total_volume_usd, reverse=True)
576
+
577
+
578
+ # ── CLI Entry Point ──────────────────────────────────────────────
579
+
580
+
581
+ if __name__ == "__main__":
582
+ import asyncio
583
+ import sys
584
+
585
+ if len(sys.argv) < 2:
586
+ print("Usage: python -m app.arkham_counterparties <address> [chain]")
587
+ sys.exit(1)
588
+
589
+ addr = sys.argv[1]
590
+ chain = sys.argv[2] if len(sys.argv) > 2 else "ethereum"
591
+
592
+ async def main():
593
+ analyzer = ArkhamCounterparties()
594
+ report = await analyzer.analyze(addr, chain)
595
+ print(report.summary())
596
+ print(f"\nTop counterparties by volume:")
597
+ for cp in report.top_counterparties_by_volume[:5]:
598
+ print(f" - {cp.entity_name or cp.address[:12]}... | ${cp.net_volume_usd:,.0f} net | {cp.tx_count} tx")
599
+
600
+ asyncio.run(main())
backend/app/test_arkham_counterparties.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for Arkham Counterparties tool
3
+ """
4
+ import pytest
5
+ from app.arkham_counterparties import (
6
+ ArkhamCounterparties,
7
+ Counterparty,
8
+ CounterpartyReport,
9
+ EntityCluster,
10
+ RelationshipType,
11
+ RiskLevel,
12
+ )
13
+
14
+
15
+ # ── Unit Tests ─────────────────────────────────────────────────────
16
+
17
+
18
+ def test_counterparty_creation():
19
+ cp = Counterparty(
20
+ address="0x1234567890123456789012345678901234567890",
21
+ chain="ethereum",
22
+ entity_name="Binance Hot Wallet",
23
+ entity_category="exchange",
24
+ )
25
+ assert cp.address == "0x1234567890123456789012345678901234567890"
26
+ assert cp.chain == "ethereum"
27
+ assert cp.entity_name == "Binance Hot Wallet"
28
+ cp_dict = cp.to_dict()
29
+ assert "address" in cp_dict
30
+ assert "relationship_type" in cp_dict
31
+
32
+
33
+ def test_report_creation():
34
+ report = CounterpartyReport(
35
+ target_address="0xabcd",
36
+ chain="ethereum",
37
+ total_interactions=100,
38
+ unique_counterparties=25,
39
+ net_volume_usd=50000.0,
40
+ )
41
+ assert report.target_address == "0xabcd"
42
+ assert report.aggregate_risk_level == "none"
43
+
44
+ report_dict = report.to_dict()
45
+ assert "summary" in report_dict
46
+ assert report_dict["summary"]["total_interactions"] == 100
47
+
48
+
49
+ def test_risk_calculation():
50
+ analyzer = ArkhamCounterparties()
51
+
52
+ # Test scam risk
53
+ score, level = analyzer._calculate_risk("scam")
54
+ assert score >= 90
55
+ assert level == "critical"
56
+
57
+ # Test exchange (low risk)
58
+ score, level = analyzer._calculate_risk("exchange")
59
+ assert score == 0.0
60
+ assert level == "none"
61
+
62
+
63
+ def test_relationship_types():
64
+ assert RelationshipType.DIRECT_TRADE.value == "direct_trade"
65
+ assert RelationshipType.FUNDING_SOURCE.value == "funding_source"
66
+ assert RelationshipType.SMART_CONTRACT.value == "smart_contract"
67
+
68
+
69
+ def test_entity_cluster():
70
+ cluster = EntityCluster(
71
+ cluster_id="binance_cluster",
72
+ addresses=["0xaaa", "0xbbb", "0xccc"],
73
+ total_volume_usd=1000000.0,
74
+ entity_hint="Binance Exchange",
75
+ confidence=85.0,
76
+ )
77
+ cluster_dict = cluster.to_dict()
78
+ assert cluster_dict["address_count"] == 3
79
+ assert cluster_dict["confidence"] == 85.0
80
+
81
+
82
+ def test_is_incoming_detection():
83
+ analyzer = ArkhamCounterparties()
84
+
85
+ # Incoming transaction
86
+ tx = {"to": "0xtarget", "from": "0xsender"}
87
+ assert analyzer._is_incoming(tx, "0xtarget") is True
88
+
89
+ # Outgoing transaction
90
+ tx = {"to": "0xrecipient", "from": "0xtarget"}
91
+ assert analyzer._is_incoming(tx, "0xtarget") is False
92
+
93
+
94
+ def test_counterparty_address_extraction():
95
+ analyzer = ArkhamCounterparties()
96
+
97
+ # Test with 'to' field
98
+ tx = {"from": "0xsender", "to": "0xrecipient"}
99
+ result = analyzer._get_counterparty_address(tx, "0xsender")
100
+ assert result == "0xrecipient"
101
+
102
+ # Test with 'from' field
103
+ result = analyzer._get_counterparty_address(tx, "0xrecipient")
104
+ assert result == "0xsender"
105
+
106
+
107
+ def test_summary_format():
108
+ report = CounterpartyReport(
109
+ target_address="0x1234567890abcdef",
110
+ chain="ethereum",
111
+ total_interactions=1000,
112
+ unique_counterparties=50,
113
+ net_volume_usd=1000000.0,
114
+ scam_exposure_count=5,
115
+ aggregate_risk_level="high",
116
+ )
117
+ summary = report.summary()
118
+ assert "HIGH" in summary
119
+ assert "1000" in summary
120
+ assert "50" in summary
121
+
122
+
123
+ # ── Integration Tests ────────────────────────────────────────────
124
+
125
+
126
+ @pytest.mark.asyncio
127
+ async def test_analyze_empty_address():
128
+ analyzer = ArkhamCounterparties()
129
+ report = await analyzer.analyze("0xnonexistent", "ethereum")
130
+ assert report.target_address == "0xnonexistent"
131
+ assert len(report.errors) > 0 or report.total_interactions == 0
132
+
133
+
134
+ if __name__ == "__main__":
135
+ pytest.main([__file__, "-v"])