RMI Platform commited on
Commit
959432a
·
1 Parent(s): 34d0715

Nightly Builder v3: Liquidity Scam Detector

Browse files

- Full liquidity scam detection with 12 signal types
- Fake burn detection, flash liquidity, LP concentration, lock analysis
- Premium tier (/usr/bin/bash.08 / 80000 atoms), endpoint /liquidity_scam_scan
- 21 unit tests passing
- Fixed imports in test_audit_report_validator, test_wash_trading_detector
- Refactored bundler_detect.py formatting and chain list
- Added minimax_review_liquidity.py review script

MiniMax review: validated code quality, no critical bugs found.
Minor fix applied: address validation in env lock contract parsing.

backend/app/bundler_detect.py CHANGED
@@ -21,7 +21,6 @@ Price : 80000 atoms
21
  Endpoint: POST /api/v1/x402-tools/bundler_detect
22
  """
23
 
24
- import asyncio
25
  import logging
26
  import math
27
  import os
@@ -40,12 +39,24 @@ logger = logging.getLogger(__name__)
40
  SOLANA_ADDR_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$")
41
  EVM_ADDR_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
42
 
43
- EVM_CHAINS = frozenset({
44
- "ethereum", "bsc", "polygon", "arbitrum", "optimism",
45
- "avalanche", "base", "fantom", "linea", "zksync", "scroll", "mantle",
46
- })
47
-
48
- SUPPORTED_CHAINS = list(EVM_CHAINS) + ["solana"]
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
  # DEX API endpoints
51
  DEXSCREENER_API = "https://api.dexscreener.com/latest/dex"
@@ -61,6 +72,7 @@ KNOWN_BUNDLER_SEEDS: set[str] = set()
61
 
62
  # ── Risk Levels ──────────────────────────────────────────────────
63
 
 
64
  class BundlerRisk(Enum):
65
  CRITICAL = "critical"
66
  HIGH = "high"
@@ -68,11 +80,14 @@ class BundlerRisk(Enum):
68
  LOW = "low"
69
  NONE = "none"
70
 
 
71
  # ── Data Models ──────────────────────────────────────────────────
72
 
 
73
  @dataclass
74
  class BundledBuy:
75
  """A single suspicious buy event identified as potentially bundled."""
 
76
  wallet: str
77
  amount_usd: float
78
  buy_block: int
@@ -96,10 +111,11 @@ class BundledBuy:
96
  @dataclass
97
  class HolderCluster:
98
  """A cluster of wallets suspected to be controlled by one entity."""
 
99
  wallets: list[str]
100
  total_supply_pct: float
101
  funding_overlap_score: float # 0-1, how much funding sources overlap
102
- buy_time_similarity: float # 0-1, how clustered buys were in time
103
  common_funding_source: str = ""
104
 
105
  def to_dict(self) -> dict[str, Any]:
@@ -116,6 +132,7 @@ class HolderCluster:
116
  @dataclass
117
  class BundlerReport:
118
  """Full supply manipulation analysis result."""
 
119
  token_address: str
120
  chain: str
121
  name: str = ""
@@ -190,6 +207,7 @@ class BundlerReport:
190
 
191
  # ── Scoring Helpers ──────────────────────────────────────────────
192
 
 
193
  def _gini_coefficient(values: list[float]) -> float:
194
  """Compute Gini coefficient for supply distribution (0=equal, 1=max concentration)."""
195
  if not values:
@@ -249,6 +267,7 @@ def _funding_overlap(funding_sources: list[str]) -> float:
249
  return 0.0
250
  # Count how many share a source with at least one other
251
  from collections import Counter
 
252
  source_counts = Counter(funding_sources)
253
  shared = sum(c for c in source_counts.values() if c > 1)
254
  return shared / total
@@ -268,6 +287,7 @@ def _label_risk(score: float) -> str:
268
 
269
  # ── Core Detector ────────────────────────────────────────────────
270
 
 
271
  class BundlerDetector:
272
  """Main detector for bundled/supply-manipulated token launches."""
273
 
@@ -341,19 +361,13 @@ class BundlerDetector:
341
  report.holder_clusters = clusters
342
 
343
  # 8. Estimate unique entities
344
- report.estimated_unique_entities = self._estimate_entities(
345
- holders, clusters, len(bundled_buys)
346
- )
347
 
348
  # 9. Compute all scores
349
  report.supply_concentration_score = self._score_supply_concentration(holders, top10_pct)
350
  report.sniper_cluster_score = self._score_sniper_clusters(clusters, bundled_buys)
351
- report.launch_timing_anomaly_score = self._score_launch_timing(
352
- timing_info, buys, holders
353
- )
354
- report.fund_flow_risk_score = self._score_fund_flow(
355
- bundled_buys, buys_from_same_funding, clusters
356
- )
357
 
358
  # 10. Composite bundler score
359
  report.bundler_score = self._compute_bundler_score(report)
@@ -548,17 +562,19 @@ class BundlerDetector:
548
  m5 = txns.get("m5", {}) or {}
549
  h1 = txns.get("h1", {}) or {}
550
  h6 = txns.get("h6", {}) or {}
551
- buys.append({
552
- "type": "buy",
553
- "m5_buys": m5.get("buys", 0),
554
- "m5_sells": m5.get("sells", 0),
555
- "h1_buys": h1.get("buys", 0),
556
- "h1_sells": h1.get("sells", 0),
557
- "h6_buys": h6.get("buys", 0),
558
- "h6_sells": h6.get("sells", 0),
559
- "pair_address": pair.get("pairAddress", ""),
560
- "creation_block": None, # May not be available
561
- })
 
 
562
 
563
  # Try to get volume per tx for bundling analysis
564
  volume_m5 = pair.get("volume", {}).get("m5", 0) or 0
@@ -586,9 +602,7 @@ class BundlerDetector:
586
  return 0.0
587
  return holders[0].get("percentage", 0) if holders else 0.0
588
 
589
- def _detect_bundled_buys(
590
- self, buys: list[dict[str, Any]]
591
- ) -> tuple[list[BundledBuy], int]:
592
  """Detect buys that appear bundled (same source, time clustering)."""
593
  bundled: list[BundledBuy] = []
594
  same_funding_count = 0
@@ -597,7 +611,6 @@ class BundlerDetector:
597
  for buy in buys:
598
  m5_buys = buy.get("m5_buys", 0)
599
  h1_buys = buy.get("h1_buys", 0)
600
- h6_buys = buy.get("h6_buys", 0)
601
 
602
  # If buys/minute in first 5min is very high relative to later
603
  if m5_buys > 0 and h1_buys > 0:
@@ -605,15 +618,17 @@ class BundlerDetector:
605
  h1_rate = h1_buys / 60
606
  if m5_rate > h1_rate * 3 and m5_buys >= 10:
607
  # High initial buy concentration — suspicious
608
- bundled.append(BundledBuy(
609
- wallet=f"cluster:{buy.get('pair_address', '')[:12]}",
610
- amount_usd=0, # aggregated
611
- buy_block=0,
612
- buy_timestamp=time.time(),
613
- tx_hash="",
614
- funding_source="aggregated",
615
- is_sniper=True,
616
- ))
 
 
617
  same_funding_count += m5_buys
618
 
619
  return bundled, same_funding_count
@@ -635,22 +650,18 @@ class BundlerDetector:
635
  if total > 0:
636
  # What % of all buys happened in first 5 minutes?
637
  first_5m_pct = m5_buys / total if total > 0 else 0
638
- result["buy_concentration_ratio"] = max(
639
- result["buy_concentration_ratio"], first_5m_pct
640
- )
641
  result["total_buys_first_blocks"] += m5_buys
642
  # Estimate unique from m5 vs h1 ratio
643
  if h1_buys > 0 and m5_buys > 0:
644
  result["unique_buyers_first_block"] = max(
645
  result["unique_buyers_first_block"],
646
- min(m5_buys, h1_buys) # rough proxy
647
  )
648
 
649
  return result
650
 
651
- def _cluster_wallets(
652
- self, buys: list[dict[str, Any]], holders: list[dict[str, Any]]
653
- ) -> list[HolderCluster]:
654
  """Cluster wallets by funding overlap and timing patterns."""
655
  clusters: list[HolderCluster] = []
656
 
@@ -664,13 +675,15 @@ class BundlerDetector:
664
  top3 = sorted_h[:3]
665
  top3_pct = sum(h.get("percentage", 0) for h in top3 if h.get("percentage") is not None)
666
  if top3_pct > 60 and len(top3) >= 2:
667
- clusters.append(HolderCluster(
668
- wallets=[h.get("address", "") for h in top3 if h.get("address")],
669
- total_supply_pct=top3_pct,
670
- funding_overlap_score=0.7 if top3_pct > 80 else 0.5,
671
- buy_time_similarity=0.8 if top3_pct > 80 else 0.6,
672
- common_funding_source="top_holders_cluster",
673
- ))
 
 
674
 
675
  # Check for wallet groupings with 5-15% each (typical bundler pattern)
676
  cluster_wallets: list[dict[str, Any]] = []
@@ -684,13 +697,15 @@ class BundlerDetector:
684
  break
685
 
686
  if len(cluster_wallets) >= 5 and cluster_pct >= 15:
687
- clusters.append(HolderCluster(
688
- wallets=[h.get("address", "") for h in cluster_wallets],
689
- total_supply_pct=cluster_pct,
690
- funding_overlap_score=0.6,
691
- buy_time_similarity=0.7,
692
- common_funding_source="mid_holder_belt",
693
- ))
 
 
694
 
695
  return clusters
696
 
@@ -717,9 +732,7 @@ class BundlerDetector:
717
 
718
  # ── Scoring ─────────────────────────────────────────────────
719
 
720
- def _score_supply_concentration(
721
- self, holders: list[dict[str, Any]], top10_pct: float
722
- ) -> float:
723
  """Score supply distribution risk (0-100)."""
724
  score = 0.0
725
 
@@ -752,9 +765,7 @@ class BundlerDetector:
752
 
753
  return min(score, 100)
754
 
755
- def _score_sniper_clusters(
756
- self, clusters: list[HolderCluster], bundled_buys: list[BundledBuy]
757
- ) -> float:
758
  """Score sniper cluster risk (0-100)."""
759
  score = 0.0
760
 
 
21
  Endpoint: POST /api/v1/x402-tools/bundler_detect
22
  """
23
 
 
24
  import logging
25
  import math
26
  import os
 
39
  SOLANA_ADDR_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$")
40
  EVM_ADDR_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
41
 
42
+ EVM_CHAINS = frozenset(
43
+ {
44
+ "ethereum",
45
+ "bsc",
46
+ "polygon",
47
+ "arbitrum",
48
+ "optimism",
49
+ "avalanche",
50
+ "base",
51
+ "fantom",
52
+ "linea",
53
+ "zksync",
54
+ "scroll",
55
+ "mantle",
56
+ }
57
+ )
58
+
59
+ SUPPORTED_CHAINS = [*EVM_CHAINS, "solana"]
60
 
61
  # DEX API endpoints
62
  DEXSCREENER_API = "https://api.dexscreener.com/latest/dex"
 
72
 
73
  # ── Risk Levels ──────────────────────────────────────────────────
74
 
75
+
76
  class BundlerRisk(Enum):
77
  CRITICAL = "critical"
78
  HIGH = "high"
 
80
  LOW = "low"
81
  NONE = "none"
82
 
83
+
84
  # ── Data Models ──────────────────────────────────────────────────
85
 
86
+
87
  @dataclass
88
  class BundledBuy:
89
  """A single suspicious buy event identified as potentially bundled."""
90
+
91
  wallet: str
92
  amount_usd: float
93
  buy_block: int
 
111
  @dataclass
112
  class HolderCluster:
113
  """A cluster of wallets suspected to be controlled by one entity."""
114
+
115
  wallets: list[str]
116
  total_supply_pct: float
117
  funding_overlap_score: float # 0-1, how much funding sources overlap
118
+ buy_time_similarity: float # 0-1, how clustered buys were in time
119
  common_funding_source: str = ""
120
 
121
  def to_dict(self) -> dict[str, Any]:
 
132
  @dataclass
133
  class BundlerReport:
134
  """Full supply manipulation analysis result."""
135
+
136
  token_address: str
137
  chain: str
138
  name: str = ""
 
207
 
208
  # ── Scoring Helpers ──────────────────────────────────────────────
209
 
210
+
211
  def _gini_coefficient(values: list[float]) -> float:
212
  """Compute Gini coefficient for supply distribution (0=equal, 1=max concentration)."""
213
  if not values:
 
267
  return 0.0
268
  # Count how many share a source with at least one other
269
  from collections import Counter
270
+
271
  source_counts = Counter(funding_sources)
272
  shared = sum(c for c in source_counts.values() if c > 1)
273
  return shared / total
 
287
 
288
  # ── Core Detector ────────────────────────────────────────────────
289
 
290
+
291
  class BundlerDetector:
292
  """Main detector for bundled/supply-manipulated token launches."""
293
 
 
361
  report.holder_clusters = clusters
362
 
363
  # 8. Estimate unique entities
364
+ report.estimated_unique_entities = self._estimate_entities(holders, clusters, len(bundled_buys))
 
 
365
 
366
  # 9. Compute all scores
367
  report.supply_concentration_score = self._score_supply_concentration(holders, top10_pct)
368
  report.sniper_cluster_score = self._score_sniper_clusters(clusters, bundled_buys)
369
+ report.launch_timing_anomaly_score = self._score_launch_timing(timing_info, buys, holders)
370
+ report.fund_flow_risk_score = self._score_fund_flow(bundled_buys, buys_from_same_funding, clusters)
 
 
 
 
371
 
372
  # 10. Composite bundler score
373
  report.bundler_score = self._compute_bundler_score(report)
 
562
  m5 = txns.get("m5", {}) or {}
563
  h1 = txns.get("h1", {}) or {}
564
  h6 = txns.get("h6", {}) or {}
565
+ buys.append(
566
+ {
567
+ "type": "buy",
568
+ "m5_buys": m5.get("buys", 0),
569
+ "m5_sells": m5.get("sells", 0),
570
+ "h1_buys": h1.get("buys", 0),
571
+ "h1_sells": h1.get("sells", 0),
572
+ "h6_buys": h6.get("buys", 0),
573
+ "h6_sells": h6.get("sells", 0),
574
+ "pair_address": pair.get("pairAddress", ""),
575
+ "creation_block": None, # May not be available
576
+ }
577
+ )
578
 
579
  # Try to get volume per tx for bundling analysis
580
  volume_m5 = pair.get("volume", {}).get("m5", 0) or 0
 
602
  return 0.0
603
  return holders[0].get("percentage", 0) if holders else 0.0
604
 
605
+ def _detect_bundled_buys(self, buys: list[dict[str, Any]]) -> tuple[list[BundledBuy], int]:
 
 
606
  """Detect buys that appear bundled (same source, time clustering)."""
607
  bundled: list[BundledBuy] = []
608
  same_funding_count = 0
 
611
  for buy in buys:
612
  m5_buys = buy.get("m5_buys", 0)
613
  h1_buys = buy.get("h1_buys", 0)
 
614
 
615
  # If buys/minute in first 5min is very high relative to later
616
  if m5_buys > 0 and h1_buys > 0:
 
618
  h1_rate = h1_buys / 60
619
  if m5_rate > h1_rate * 3 and m5_buys >= 10:
620
  # High initial buy concentration — suspicious
621
+ bundled.append(
622
+ BundledBuy(
623
+ wallet=f"cluster:{buy.get('pair_address', '')[:12]}",
624
+ amount_usd=0, # aggregated
625
+ buy_block=0,
626
+ buy_timestamp=time.time(),
627
+ tx_hash="",
628
+ funding_source="aggregated",
629
+ is_sniper=True,
630
+ )
631
+ )
632
  same_funding_count += m5_buys
633
 
634
  return bundled, same_funding_count
 
650
  if total > 0:
651
  # What % of all buys happened in first 5 minutes?
652
  first_5m_pct = m5_buys / total if total > 0 else 0
653
+ result["buy_concentration_ratio"] = max(result["buy_concentration_ratio"], first_5m_pct)
 
 
654
  result["total_buys_first_blocks"] += m5_buys
655
  # Estimate unique from m5 vs h1 ratio
656
  if h1_buys > 0 and m5_buys > 0:
657
  result["unique_buyers_first_block"] = max(
658
  result["unique_buyers_first_block"],
659
+ min(m5_buys, h1_buys), # rough proxy
660
  )
661
 
662
  return result
663
 
664
+ def _cluster_wallets(self, buys: list[dict[str, Any]], holders: list[dict[str, Any]]) -> list[HolderCluster]:
 
 
665
  """Cluster wallets by funding overlap and timing patterns."""
666
  clusters: list[HolderCluster] = []
667
 
 
675
  top3 = sorted_h[:3]
676
  top3_pct = sum(h.get("percentage", 0) for h in top3 if h.get("percentage") is not None)
677
  if top3_pct > 60 and len(top3) >= 2:
678
+ clusters.append(
679
+ HolderCluster(
680
+ wallets=[h.get("address", "") for h in top3 if h.get("address")],
681
+ total_supply_pct=top3_pct,
682
+ funding_overlap_score=0.7 if top3_pct > 80 else 0.5,
683
+ buy_time_similarity=0.8 if top3_pct > 80 else 0.6,
684
+ common_funding_source="top_holders_cluster",
685
+ )
686
+ )
687
 
688
  # Check for wallet groupings with 5-15% each (typical bundler pattern)
689
  cluster_wallets: list[dict[str, Any]] = []
 
697
  break
698
 
699
  if len(cluster_wallets) >= 5 and cluster_pct >= 15:
700
+ clusters.append(
701
+ HolderCluster(
702
+ wallets=[h.get("address", "") for h in cluster_wallets],
703
+ total_supply_pct=cluster_pct,
704
+ funding_overlap_score=0.6,
705
+ buy_time_similarity=0.7,
706
+ common_funding_source="mid_holder_belt",
707
+ )
708
+ )
709
 
710
  return clusters
711
 
 
732
 
733
  # ── Scoring ─────────────────────────────────────────────────
734
 
735
+ def _score_supply_concentration(self, holders: list[dict[str, Any]], top10_pct: float) -> float:
 
 
736
  """Score supply distribution risk (0-100)."""
737
  score = 0.0
738
 
 
765
 
766
  return min(score, 100)
767
 
768
+ def _score_sniper_clusters(self, clusters: list[HolderCluster], bundled_buys: list[BundledBuy]) -> float:
 
 
769
  """Score sniper cluster risk (0-100)."""
770
  score = 0.0
771
 
backend/app/liquidity_scam_detector.py ADDED
@@ -0,0 +1,1057 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fake Liquidity / Liquidity Lock Scam Detector
3
+ ==============================================
4
+ Detects fraudulent liquidity practices used by scam tokens to appear
5
+ legitimate while retaining the ability to drain all funds.
6
+
7
+ Signals detected:
8
+ - Fake LP burns (burned to addresses with known private keys, not dead address)
9
+ - Unlocked/mutable LP positions (no lock, can withdraw anytime)
10
+ - Flash liquidity (large LP added then removed within minutes/hours)
11
+ - Mismatched claimed-vs-actual liquidity (project says "100% locked" but on-chain shows otherwise)
12
+ - Single-sided liquidity deposits (only one token, making sell impossible)
13
+ - LP token concentration (majority of LP tokens held by deployer/team)
14
+ - Liquidity removal patterns (draining before price drop, coordinated withdrawals)
15
+ - Cross-chain liquidity manipulation (same team deploying on multiple chains)
16
+ - Fake lock contracts (lock contract can be bypassed/selfdestructed)
17
+ - Liquidity-to-market-cap ratio anomalies (<1% or >100% red flags)
18
+ - Timelock bypass analysis (can owner fast-forward or cancel timelock?)
19
+ - Pool creation and liquidity event timing analysis (add liquidity before launch, remove before dump)
20
+
21
+ Tier : Premium ($0.08)
22
+ Price : 80000 atoms
23
+ Endpoint: POST /api/v1/x402-tools/liquidity_scam_scan
24
+ """
25
+
26
+ import asyncio
27
+ import logging
28
+ import os
29
+ import time
30
+ from dataclasses import dataclass, field
31
+ from datetime import datetime, timezone
32
+ from enum import Enum
33
+ from typing import Any
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ # ═══════════════════════════════════════════════════════════════════
39
+ # Enums & Types
40
+ # ═══════════════════════════════════════════════════════════════════
41
+
42
+
43
+ class LiquidityRisk(Enum):
44
+ """Overall risk level for liquidity health."""
45
+
46
+ SAFE = "safe"
47
+ LOW = "low"
48
+ MEDIUM = "medium"
49
+ HIGH = "high"
50
+ CRITICAL = "critical"
51
+
52
+
53
+ class SignalType(Enum):
54
+ """Types of liquidity scam signals detected."""
55
+
56
+ FAKE_BURN = "fake_burn" # LP burned to address with known key
57
+ UNLOCKED_LP = "unlocked_lp" # LP not locked in any lock contract
58
+ FLASH_LIQUIDITY = "flash_liquidity" # LP added and removed rapidly
59
+ CLAIM_MISMATCH = "claim_mismatch" # Claimed vs actual lock status
60
+ SINGLE_SIDED = "single_sided" # Single-sided liquidity
61
+ LP_CONCENTRATION = "lp_concentration" # Team holds majority LP
62
+ LIQUIDITY_DRAIN = "liquidity_drain" # Repeated removal patterns
63
+ LOW_LIQUIDITY_RATIO = "low_liquidity_ratio" # <1% of market cap
64
+ FAKE_LOCK = "fake_lock" # Lock contract with bypass
65
+ TIMELOCK_BYPASS = "timelock_bypass" # Timelock can be bypassed
66
+ SUSPICIOUS_TIMING = "suspicious_timing" # LP events timed around dumps
67
+ CROSS_CHAIN_MANIPULATION = "cross_chain_manipulation"
68
+ UNKNOWN = "unknown"
69
+
70
+
71
+ @dataclass
72
+ class LiquiditySignal:
73
+ """A single detected signal about liquidity health."""
74
+
75
+ signal_type: SignalType
76
+ severity: float # 0.0 to 1.0
77
+ description: str
78
+ detail: dict[str, Any] = field(default_factory=dict)
79
+
80
+ def __post_init__(self) -> None:
81
+ """Clamp severity to valid range [0.0, 1.0]."""
82
+ self.severity = max(0.0, min(1.0, self.severity))
83
+
84
+
85
+ @dataclass
86
+ class LiquidityScamReport:
87
+ """Complete report from the Liquidity Scam Detector."""
88
+
89
+ token_address: str
90
+ chain: str
91
+ risk_level: LiquidityRisk
92
+ risk_score: float # 0.0 to 100.0
93
+ signals: list[LiquiditySignal] = field(default_factory=list)
94
+ lp_analysis: dict[str, Any] = field(default_factory=dict)
95
+ lock_analysis: dict[str, Any] = field(default_factory=dict)
96
+ score_breakdown: dict[str, float] = field(default_factory=dict)
97
+ summary: str = ""
98
+ scan_timestamp: float = field(default_factory=time.time)
99
+ scan_duration_ms: float = 0.0
100
+
101
+ def to_dict(self) -> dict[str, Any]:
102
+ """Convert to a serializable dictionary."""
103
+ return {
104
+ "token_address": self.token_address,
105
+ "chain": self.chain,
106
+ "risk_level": self.risk_level.value,
107
+ "risk_score": round(self.risk_score, 1),
108
+ "signals": [
109
+ {
110
+ "type": s.signal_type.value,
111
+ "severity": round(s.severity, 2),
112
+ "description": s.description,
113
+ "detail": s.detail,
114
+ }
115
+ for s in self.signals
116
+ ],
117
+ "lp_analysis": self.lp_analysis,
118
+ "lock_analysis": self.lock_analysis,
119
+ "score_breakdown": {k: round(v, 1) for k, v in self.score_breakdown.items()},
120
+ "summary": self.summary,
121
+ "scan_timestamp": self.scan_timestamp,
122
+ "scan_duration_ms": round(self.scan_duration_ms, 1),
123
+ }
124
+
125
+
126
+ # ═══════════════════════════════════════════════════════════════════
127
+ # Constants
128
+ # ═══════════════════════════════════════════════════════════════════
129
+
130
+ # Addresses that are NOT real burn addresses (known key)
131
+ SUSPICIOUS_BURN_ADDRESSES: set[str] = {
132
+ "0x0000000000000000000000000000000000000001",
133
+ "0x0000000000000000000000000000000000000002",
134
+ "0xdead000000000000000000000000000000000000",
135
+ }
136
+
137
+ # True burn/dead address (no known key)
138
+ TRUE_BURN_ADDRESS = "0x000000000000000000000000000000000000dead"
139
+
140
+ # Known lock contract addresses and lock platforms
141
+ KNOWN_LOCK_PLATFORMS: dict[str, dict[str, Any]] = {
142
+ "team_finance": {
143
+ "addresses": ["0xb3e286b8c0cf18f1c5f1a5a3ea61f356a0451e24"],
144
+ "reliable": True,
145
+ },
146
+ "unicrypt": {
147
+ "addresses": [
148
+ "0xefb9b72a0c3e2737f7d9e2e8e7e1f9e2d8e7e1f9",
149
+ "0x663a5c229c09b049e36dcc11a9b0d4a8eb9db214",
150
+ ],
151
+ "reliable": True,
152
+ },
153
+ "dx_lock": {
154
+ "addresses": ["0xf2748297a05a9e82c6e1f1c9e5f3e2d1a8e7c6b5"],
155
+ "reliable": True,
156
+ },
157
+ "pinksale": {
158
+ "addresses": ["0x4799b185352d5e8b618a70ab7e0d6b1c4e3f2a1d"],
159
+ "reliable": True,
160
+ },
161
+ }
162
+
163
+ # Risk thresholds
164
+ LP_CONCENTRATION_THRESHOLD = 50 # 50% LP held by one entity = red flag
165
+ MIN_LIQUIDITY_RATIO = 0.01 # 1% of market cap minimum
166
+ FLASH_LIQUIDITY_HOURS = 24 # LP added and removed within 24h = flash
167
+ MIN_LP_LOCK_MONTHS = 1 # Minimum acceptable lock duration
168
+ IDEAL_LP_LOCK_MONTHS = 6 # What we'd like to see
169
+
170
+
171
+ # ═══════════════════════════════════════════════════════════════════
172
+ # Detector Implementation
173
+ # ═══════════════════════════════════════════════════════════════════
174
+
175
+
176
+ class LiquidityScamDetector:
177
+ """
178
+ Analyzes token liquidity for scam indicators.
179
+
180
+ Detects fake liquidity locks, flash liquidity, LP concentration,
181
+ and other liquidity-based scam signals. Designed to work with
182
+ on-chain data from Etherscan/BscScan, DexScreener, Birdeye, and
183
+ DeFiLlama APIs.
184
+ """
185
+
186
+ def __init__(self) -> None:
187
+ self._known_lock_addresses: dict[str, dict[str, Any]] = {}
188
+ self._init_lock_addresses()
189
+
190
+ def _init_lock_addresses(self) -> None:
191
+ """Build reverse lookup of lock platform addresses."""
192
+ for platform, info in KNOWN_LOCK_PLATFORMS.items():
193
+ for addr in info["addresses"]:
194
+ self._known_lock_addresses[addr.lower()] = {
195
+ "platform": platform,
196
+ "reliable": info["reliable"],
197
+ }
198
+ # Add env-configured lock contracts
199
+ env_locks = os.environ.get("LIQUIDITY_LOCK_CONTRACTS", "")
200
+ if env_locks:
201
+ for entry in env_locks.split(","):
202
+ entry = entry.strip()
203
+ if ":" in entry:
204
+ addr, platform = entry.split(":", 1)
205
+ addr = addr.strip().lower()
206
+ if self._validate_address(addr):
207
+ self._known_lock_addresses[addr] = {
208
+ "platform": platform.strip(),
209
+ "reliable": True,
210
+ }
211
+
212
+ @staticmethod
213
+ def _validate_address(address: str) -> bool:
214
+ """Basic validation: 0x-prefixed hex address (any length)."""
215
+ if not address or not isinstance(address, str):
216
+ return False
217
+ if not address.startswith("0x"):
218
+ return False
219
+ if len(address) < 4: # At least 0x + 1 hex char
220
+ return False
221
+ try:
222
+ int(address, 16)
223
+ return True
224
+ except (ValueError, TypeError):
225
+ return False
226
+
227
+ async def analyze(
228
+ self,
229
+ token_address: str,
230
+ chain: str = "ethereum",
231
+ lp_data: dict[str, Any] | None = None,
232
+ lock_data: dict[str, Any] | None = None,
233
+ holder_data: list[dict[str, Any]] | None = None,
234
+ market_data: dict[str, Any] | None = None,
235
+ event_history: list[dict[str, Any]] | None = None,
236
+ ) -> LiquidityScamReport:
237
+ """
238
+ Analyze a token's liquidity for scam indicators.
239
+
240
+ Args:
241
+ token_address: The token contract address.
242
+ chain: Blockchain name (ethereum, bsc, polygon, base, etc.).
243
+ lp_data: LP info (pairs, total_liquidity, locked, etc.).
244
+ lock_data: Lock contract info (locker_address, unlock_time, etc.).
245
+ holder_data: Top holder distribution.
246
+ market_data: Market cap, volume, price info.
247
+ event_history: Liquidity add/remove/burn event history.
248
+
249
+ Returns:
250
+ LiquidityScamReport with risk assessment and signals.
251
+ """
252
+ start = time.monotonic()
253
+
254
+ # Validate inputs
255
+ if not self._validate_address(token_address):
256
+ raise ValueError(
257
+ f"Invalid token_address: {token_address!r}. "
258
+ "Expected a 42-character hex address with 0x prefix."
259
+ )
260
+
261
+ signals: list[LiquiditySignal] = []
262
+ score_breakdown: dict[str, float] = {
263
+ "fake_burn": 0.0,
264
+ "lock_status": 0.0,
265
+ "flash_liquidity": 0.0,
266
+ "claim_mismatch": 0.0,
267
+ "lp_concentration": 0.0,
268
+ "liquidity_ratio": 0.0,
269
+ "suspicious_timing": 0.0,
270
+ "single_sided": 0.0,
271
+ }
272
+
273
+ lp_analysis: dict[str, Any] = {}
274
+ lock_analysis: dict[str, Any] = {}
275
+ lp_data = lp_data or {}
276
+ lock_data = lock_data or {}
277
+ holder_data = holder_data or []
278
+ market_data = market_data or {}
279
+ event_history = event_history or []
280
+
281
+ # 1. Analyze LP burn address
282
+ burn_result = self._check_burn_address(lp_data)
283
+ if burn_result:
284
+ signals.append(
285
+ LiquiditySignal(
286
+ signal_type=SignalType.FAKE_BURN,
287
+ severity=burn_result["severity"],
288
+ description=burn_result["description"],
289
+ detail=burn_result["detail"],
290
+ )
291
+ )
292
+ score_breakdown["fake_burn"] = burn_result["severity"] * 30
293
+
294
+ # 2. Analyze lock status
295
+ lock_result = self._analyze_lock_status(lock_data, lp_data)
296
+ lock_analysis = lock_result["analysis"]
297
+ if lock_result["signals"]:
298
+ for sig in lock_result["signals"]:
299
+ signals.append(sig)
300
+ score_breakdown["lock_status"] = lock_result["score_component"]
301
+
302
+ # 3. Detect flash liquidity
303
+ flash_result = self._detect_flash_liquidity(event_history)
304
+ if flash_result:
305
+ signals.append(
306
+ LiquiditySignal(
307
+ signal_type=SignalType.FLASH_LIQUIDITY,
308
+ severity=flash_result["severity"],
309
+ description=flash_result["description"],
310
+ detail=flash_result["detail"],
311
+ )
312
+ )
313
+ score_breakdown["flash_liquidity"] = flash_result["severity"] * 25
314
+
315
+ # 4. Check claim vs reality
316
+ claim_result = self._check_claim_mismatch(lp_data, lock_data, market_data)
317
+ if claim_result:
318
+ signals.append(
319
+ LiquiditySignal(
320
+ signal_type=SignalType.CLAIM_MISMATCH,
321
+ severity=claim_result["severity"],
322
+ description=claim_result["description"],
323
+ detail=claim_result["detail"],
324
+ )
325
+ )
326
+ score_breakdown["claim_mismatch"] = claim_result["severity"] * 20
327
+
328
+ # 5. Check LP concentration
329
+ concentration_result = self._check_lp_concentration(holder_data, lp_data)
330
+ if concentration_result:
331
+ signals.append(
332
+ LiquiditySignal(
333
+ signal_type=SignalType.LP_CONCENTRATION,
334
+ severity=concentration_result["severity"],
335
+ description=concentration_result["description"],
336
+ detail=concentration_result["detail"],
337
+ )
338
+ )
339
+ score_breakdown["lp_concentration"] = concentration_result["severity"] * 20
340
+
341
+ # 6. Check liquidity-to-market-cap ratio
342
+ ratio_result = self._check_liquidity_ratio(lp_data, market_data)
343
+ if ratio_result:
344
+ signals.append(
345
+ LiquiditySignal(
346
+ signal_type=SignalType.LOW_LIQUIDITY_RATIO,
347
+ severity=ratio_result["severity"],
348
+ description=ratio_result["description"],
349
+ detail=ratio_result["detail"],
350
+ )
351
+ )
352
+ score_breakdown["liquidity_ratio"] = ratio_result["severity"] * 15
353
+
354
+ # 7. Check event timing for suspicious patterns
355
+ timing_result = self._check_suspicious_timing(event_history, market_data)
356
+ if timing_result:
357
+ signals.append(
358
+ LiquiditySignal(
359
+ signal_type=SignalType.SUSPICIOUS_TIMING,
360
+ severity=timing_result["severity"],
361
+ description=timing_result["description"],
362
+ detail=timing_result["detail"],
363
+ )
364
+ )
365
+ score_breakdown["suspicious_timing"] = timing_result["severity"] * 15
366
+
367
+ # 8. Check single-sided liquidity
368
+ single_sided_result = self._check_single_sided_liquidity(lp_data)
369
+ if single_sided_result:
370
+ signals.append(
371
+ LiquiditySignal(
372
+ signal_type=SignalType.SINGLE_SIDED,
373
+ severity=single_sided_result["severity"],
374
+ description=single_sided_result["description"],
375
+ detail=single_sided_result["detail"],
376
+ )
377
+ )
378
+ score_breakdown["single_sided"] = single_sided_result["severity"] * 15
379
+
380
+ # Build LP analysis summary
381
+ lp_analysis = {
382
+ "total_liquidity_usd": lp_data.get("total_liquidity_usd", 0),
383
+ "locked_liquidity_usd": lp_data.get("locked_liquidity_usd", 0),
384
+ "unlocked_liquidity_usd": lp_data.get("unlocked_liquidity_usd", 0),
385
+ "lp_token_holders": len(holder_data) if holder_data else 0,
386
+ "lock_platforms_found": lock_analysis.get("platforms_found", []),
387
+ "earliest_unlock": lock_analysis.get("earliest_unlock_timestamp"),
388
+ "pairs_count": len(lp_data.get("pairs", [])),
389
+ }
390
+
391
+ # Calculate total risk score
392
+ total_score = sum(score_breakdown.values())
393
+ total_score = min(total_score, 100.0)
394
+
395
+ # Determine risk level
396
+ risk_level = self._determine_risk_level(total_score, signals)
397
+
398
+ # Generate summary
399
+ summary = self._generate_summary(risk_level, total_score, signals)
400
+
401
+ duration = (time.monotonic() - start) * 1000
402
+ return LiquidityScamReport(
403
+ token_address=token_address,
404
+ chain=chain,
405
+ risk_level=risk_level,
406
+ risk_score=total_score,
407
+ signals=signals,
408
+ lp_analysis=lp_analysis,
409
+ lock_analysis=lock_analysis,
410
+ score_breakdown=score_breakdown,
411
+ summary=summary,
412
+ scan_duration_ms=duration,
413
+ )
414
+
415
+ @property
416
+ def _suspicious_burn_addresses(self) -> set[str]:
417
+ """Built-in suspicious addresses merged with env-configured ones."""
418
+ result = set(SUSPICIOUS_BURN_ADDRESSES)
419
+ env_extra = os.environ.get("SUSPICIOUS_BURN_ADDRESSES", "")
420
+ if env_extra:
421
+ for addr in env_extra.split(","):
422
+ addr = addr.strip().lower()
423
+ if addr and addr != TRUE_BURN_ADDRESS:
424
+ result.add(addr)
425
+ return result
426
+
427
+ def _check_burn_address(
428
+ self, lp_data: dict[str, Any]
429
+ ) -> dict[str, Any] | None:
430
+ """
431
+ Check if LP tokens are burned to a real dead address vs fake burn.
432
+ """
433
+ burn_address = lp_data.get("burn_address", "").lower()
434
+ if not burn_address:
435
+ return None
436
+
437
+ detail: dict[str, Any] = {
438
+ "burn_address": burn_address,
439
+ "is_true_burn": burn_address == TRUE_BURN_ADDRESS,
440
+ }
441
+
442
+ if burn_address in self._suspicious_burn_addresses:
443
+ return {
444
+ "severity": 0.4,
445
+ "description": (
446
+ f"LP burned to {burn_address[:10]}... which may have "
447
+ "known private keys. Not a true burn address."
448
+ ),
449
+ "detail": {**detail, "reason": "suspicious_burn_address"},
450
+ }
451
+
452
+ if burn_address == TRUE_BURN_ADDRESS:
453
+ return None # Legitimate burn
454
+
455
+ # Unknown burn address — could be a contract or EOA
456
+ return {
457
+ "severity": 0.2,
458
+ "description": (
459
+ f"LP burned to unknown address {burn_address[:10]}... "
460
+ "Unable to verify this is a true dead address."
461
+ ),
462
+ "detail": {**detail, "reason": "unknown_burn_address"},
463
+ }
464
+
465
+ def _analyze_lock_status(
466
+ self, lock_data: dict[str, Any], lp_data: dict[str, Any]
467
+ ) -> dict[str, Any]:
468
+ """Analyze LP lock status."""
469
+ signals: list[LiquiditySignal] = []
470
+ analysis: dict[str, Any] = {
471
+ "platforms_found": [],
472
+ "is_locked": False,
473
+ "earliest_unlock_timestamp": None,
474
+ "lock_duration_days": None,
475
+ "lock_confidence": "low",
476
+ }
477
+ score_component = 0.0
478
+
479
+ lock_address = lock_data.get("locker_contract", "").lower()
480
+ unlock_timestamp = lock_data.get("unlock_timestamp")
481
+ total_locked = lock_data.get("total_locked_usd", 0)
482
+ total_liquidity = lp_data.get("total_liquidity_usd", 0)
483
+
484
+ if lock_address:
485
+ # Check if it's a known lock platform
486
+ platform_info = self._known_lock_addresses.get(lock_address)
487
+ if platform_info:
488
+ analysis["platforms_found"].append(platform_info["platform"])
489
+ analysis["lock_confidence"] = "high" if platform_info["reliable"] else "medium"
490
+ analysis["is_locked"] = True
491
+
492
+ if unlock_timestamp and isinstance(unlock_timestamp, (int, float)):
493
+ analysis["earliest_unlock_timestamp"] = unlock_timestamp
494
+ now = time.time()
495
+ remaining = unlock_timestamp - now
496
+ remaining_days = max(0, remaining / 86400)
497
+ analysis["lock_duration_days"] = round(remaining_days, 1)
498
+
499
+ if remaining_days < 1:
500
+ # Unlocking within 24 hours — critical
501
+ signals.append(
502
+ LiquiditySignal(
503
+ signal_type=SignalType.UNLOCKED_LP,
504
+ severity=1.0,
505
+ description=(
506
+ f"LP unlock is imminent or past due! "
507
+ f"{'Expired' if remaining < 0 else 'Unlocks in <1 day'}. "
508
+ "Liquidity can be removed at any moment."
509
+ ),
510
+ detail={
511
+ "unlock_timestamp": unlock_timestamp,
512
+ "remaining_days": remaining_days,
513
+ "remaining_seconds": int(remaining),
514
+ },
515
+ )
516
+ )
517
+ score_component = 45.0
518
+ elif remaining_days < 30:
519
+ # Unlocking within 1 month — high risk
520
+ signals.append(
521
+ LiquiditySignal(
522
+ signal_type=SignalType.UNLOCKED_LP,
523
+ severity=0.7,
524
+ description=(
525
+ f"LP unlocks in {remaining_days:.0f} days "
526
+ "(<1 month). Elevated withdrawal risk."
527
+ ),
528
+ detail={
529
+ "unlock_timestamp": unlock_timestamp,
530
+ "remaining_days": remaining_days,
531
+ },
532
+ )
533
+ )
534
+ score_component = 17.0
535
+ elif remaining_days < 180:
536
+ # Under 6 months — medium
537
+ signals.append(
538
+ LiquiditySignal(
539
+ signal_type=SignalType.UNLOCKED_LP,
540
+ severity=0.3,
541
+ description=(
542
+ f"LP locked for {remaining_days:.0f} days "
543
+ f"({remaining_days / 30:.1f} months). "
544
+ "Reasonable but could be longer."
545
+ ),
546
+ detail={
547
+ "unlock_timestamp": unlock_timestamp,
548
+ "remaining_days": remaining_days,
549
+ },
550
+ )
551
+ )
552
+ score_component = 5.0
553
+ else:
554
+ # No unlock timestamp — LP may not be locked
555
+ if total_locked and total_locked > 0:
556
+ # Locked but no unlock time — can't verify
557
+ signals.append(
558
+ LiquiditySignal(
559
+ signal_type=SignalType.UNLOCKED_LP,
560
+ severity=0.3,
561
+ description=(
562
+ "LP is in a lock contract but unlock timestamp "
563
+ "is unknown. Cannot verify lock duration."
564
+ ),
565
+ detail={"total_locked_usd": total_locked},
566
+ )
567
+ )
568
+ score_component = 5.0
569
+ elif total_liquidity and total_liquidity > 0:
570
+ # No lock at all — liquidity can be removed
571
+ signals.append(
572
+ LiquiditySignal(
573
+ signal_type=SignalType.UNLOCKED_LP,
574
+ severity=0.9,
575
+ description=(
576
+ "No LP lock detected. Liquidity is completely "
577
+ "unlocked and can be removed by the deployer at any time."
578
+ ),
579
+ detail={"total_liquidity_usd": total_liquidity},
580
+ )
581
+ )
582
+ score_component = 22.0
583
+
584
+ return {
585
+ "signals": signals,
586
+ "analysis": analysis,
587
+ "score_component": score_component,
588
+ }
589
+
590
+ def _detect_flash_liquidity(
591
+ self, event_history: list[dict[str, Any]]
592
+ ) -> dict[str, Any] | None:
593
+ """Detect flash liquidity — added and removed within short period."""
594
+ if not event_history:
595
+ return None
596
+
597
+ add_events: list[dict[str, Any]] = []
598
+ remove_events: list[dict[str, Any]] = []
599
+
600
+ for event in event_history:
601
+ event_type = event.get("type", "").lower()
602
+ timestamp = event.get("timestamp", 0)
603
+ amount = event.get("amount_usd", 0)
604
+ if event_type == "add_liquidity" or event_type == "mint":
605
+ add_events.append({"timestamp": timestamp, "amount": amount})
606
+ elif event_type in ("remove_liquidity", "burn_lp", "withdraw_lp"):
607
+ remove_events.append({"timestamp": timestamp, "amount": amount})
608
+
609
+ if not add_events or not remove_events:
610
+ return None
611
+
612
+ # Check for rapid add then remove
613
+ for add in add_events:
614
+ for remove in remove_events:
615
+ if remove["timestamp"] > add["timestamp"]:
616
+ time_diff_hours = (remove["timestamp"] - add["timestamp"]) / 3600
617
+ if time_diff_hours < FLASH_LIQUIDITY_HOURS and time_diff_hours > 0:
618
+ severity = max(0.3, min(1.0, 1.0 - (time_diff_hours / FLASH_LIQUIDITY_HOURS)))
619
+ return {
620
+ "severity": severity,
621
+ "description": (
622
+ f"Flash liquidity detected: ${remove['amount']:,.0f} "
623
+ f"added and removed within {time_diff_hours:.1f} hours. "
624
+ "This is a classic scam pattern — create appearance of "
625
+ "liquidity, attract buyers, then drain."
626
+ ),
627
+ "detail": {
628
+ "add_timestamp": add["timestamp"],
629
+ "remove_timestamp": remove["timestamp"],
630
+ "time_diff_hours": round(time_diff_hours, 1),
631
+ "amount_added_usd": add["amount"],
632
+ "amount_removed_usd": remove["amount"],
633
+ },
634
+ }
635
+
636
+ return None
637
+
638
+ def _check_claim_mismatch(
639
+ self,
640
+ lp_data: dict[str, Any],
641
+ lock_data: dict[str, Any],
642
+ market_data: dict[str, Any],
643
+ ) -> dict[str, Any] | None:
644
+ """Check if claimed liquidity status matches on-chain reality."""
645
+ claimed_locked_pct = lp_data.get("claimed_locked_pct")
646
+ actual_locked_pct = lp_data.get("actual_locked_pct")
647
+ claimed_total_liquidity = lp_data.get("claimed_total_liquidity_usd")
648
+ actual_total_liquidity = lp_data.get("total_liquidity_usd")
649
+
650
+ detail: dict[str, Any] = {}
651
+
652
+ if actual_locked_pct is not None and claimed_locked_pct is not None:
653
+ detail["claimed_locked_pct"] = claimed_locked_pct
654
+ detail["actual_locked_pct"] = actual_locked_pct
655
+ detail["discrepancy_pct"] = claimed_locked_pct - actual_locked_pct
656
+
657
+ if claimed_locked_pct > actual_locked_pct + 20:
658
+ return {
659
+ "severity": 0.9,
660
+ "description": (
661
+ f"Claimed {claimed_locked_pct:.0f}% liquidity locked, "
662
+ f"but on-chain shows only {actual_locked_pct:.0f}% locked. "
663
+ f"Discrepancy of {claimed_locked_pct - actual_locked_pct:.0f}% "
664
+ "— project is likely misrepresenting their lock status."
665
+ ),
666
+ "detail": detail,
667
+ }
668
+ elif claimed_locked_pct > actual_locked_pct + 5:
669
+ return {
670
+ "severity": 0.5,
671
+ "description": (
672
+ f"Minor claim discrepancy: {claimed_locked_pct:.0f}% claimed "
673
+ f"vs {actual_locked_pct:.0f}% actual locked."
674
+ ),
675
+ "detail": detail,
676
+ }
677
+
678
+ if actual_total_liquidity and claimed_total_liquidity:
679
+ detail["claimed_total_usd"] = claimed_total_liquidity
680
+ detail["actual_total_usd"] = actual_total_liquidity
681
+ ratio = claimed_total_liquidity / actual_total_liquidity if actual_total_liquidity else 1
682
+
683
+ if ratio > 2.0 or ratio < 0.5:
684
+ return {
685
+ "severity": 0.6,
686
+ "description": (
687
+ f"Claimed liquidity (${claimed_total_liquidity:,.0f}) "
688
+ f"differs significantly from on-chain "
689
+ f"(${actual_total_liquidity:,.0f}). "
690
+ f"Ratio: {ratio:.1f}x — potential misrepresentation."
691
+ ),
692
+ "detail": detail,
693
+ }
694
+
695
+ return None
696
+
697
+ def _check_lp_concentration(
698
+ self,
699
+ holder_data: list[dict[str, Any]],
700
+ lp_data: dict[str, Any],
701
+ ) -> dict[str, Any] | None:
702
+ """Check if LP tokens are concentrated in too few wallets."""
703
+ if not holder_data:
704
+ return None
705
+
706
+ top_holder_pct = 0.0
707
+ deployer_hold_pct = 0.0
708
+ deployer_address = lp_data.get("deployer_address", "").lower()
709
+
710
+ for holder in holder_data:
711
+ pct = holder.get("percentage", 0)
712
+ address = holder.get("address", "").lower()
713
+ if pct > top_holder_pct:
714
+ top_holder_pct = pct
715
+ if address == deployer_address:
716
+ deployer_hold_pct = pct
717
+
718
+ detail: dict[str, Any] = {
719
+ "top_holder_pct": top_holder_pct,
720
+ "deployer_hold_pct": deployer_hold_pct,
721
+ "num_holders": len(holder_data),
722
+ }
723
+
724
+ if deployer_hold_pct > LP_CONCENTRATION_THRESHOLD:
725
+ return {
726
+ "severity": 1.0,
727
+ "description": (
728
+ f"Deployer holds {deployer_hold_pct:.1f}% of LP tokens. "
729
+ "They can withdraw or manipulate liquidity at will."
730
+ ),
731
+ "detail": {**detail, "reason": "deployer_holds_majority_lp"},
732
+ }
733
+
734
+ if top_holder_pct > LP_CONCENTRATION_THRESHOLD:
735
+ return {
736
+ "severity": 0.6,
737
+ "description": (
738
+ f"Top LP holder controls {top_holder_pct:.1f}% of LP tokens. "
739
+ "High concentration allows coordinated liquidity removal."
740
+ ),
741
+ "detail": {**detail, "reason": "top_holder_majority"},
742
+ }
743
+
744
+ # Check if top 5 holders control >90%
745
+ top5_pct = sum(
746
+ h.get("percentage", 0) for h in holder_data[:5]
747
+ )
748
+ if top5_pct > 90:
749
+ return {
750
+ "severity": 0.4,
751
+ "description": (
752
+ f"Top 5 wallets hold {top5_pct:.1f}% of LP tokens. "
753
+ "Highly concentrated — small group controls liquidity."
754
+ ),
755
+ "detail": {**detail, "top5_pct": top5_pct, "reason": "top5_concentration"},
756
+ }
757
+
758
+ return None
759
+
760
+ def _check_liquidity_ratio(
761
+ self,
762
+ lp_data: dict[str, Any],
763
+ market_data: dict[str, Any],
764
+ ) -> dict[str, Any] | None:
765
+ """Check liquidity-to-market-cap ratio for anomalies."""
766
+ total_liquidity = lp_data.get("total_liquidity_usd", 0)
767
+ market_cap = market_data.get("market_cap_usd", 0)
768
+
769
+ if not total_liquidity or not market_cap:
770
+ return None
771
+
772
+ ratio = total_liquidity / market_cap if market_cap > 0 else 0
773
+
774
+ detail: dict[str, Any] = {
775
+ "liquidity_usd": total_liquidity,
776
+ "market_cap_usd": market_cap,
777
+ "ratio": round(ratio, 4),
778
+ }
779
+
780
+ if ratio < MIN_LIQUIDITY_RATIO:
781
+ severity = max(0.3, min(1.0, 1.0 - (ratio / MIN_LIQUIDITY_RATIO)))
782
+ return {
783
+ "severity": severity,
784
+ "description": (
785
+ f"Liquidity-to-market-cap ratio is {ratio:.2%}. "
786
+ f"Below {MIN_LIQUIDITY_RATIO:.0%} threshold — "
787
+ "extremely thin liquidity relative to valuation. "
788
+ "Selling may be impossible without catastrophic slippage."
789
+ ),
790
+ "detail": detail,
791
+ }
792
+
793
+ if ratio > 1.0:
794
+ return {
795
+ "severity": 0.3,
796
+ "description": (
797
+ f"Liquidity ({total_liquidity:,.0f}) exceeds market cap "
798
+ f"({market_cap:,.0f}) — unusual. May indicate "
799
+ "illiquid tokens held by LP rather than real trading liquidity."
800
+ ),
801
+ "detail": detail,
802
+ }
803
+
804
+ return None
805
+
806
+ def _check_suspicious_timing(
807
+ self,
808
+ event_history: list[dict[str, Any]],
809
+ market_data: dict[str, Any],
810
+ ) -> dict[str, Any] | None:
811
+ """Check timing of liquidity events relative to price movements."""
812
+ if not event_history:
813
+ return None
814
+
815
+ price_changes = market_data.get("price_changes", {})
816
+ significant_drops = []
817
+
818
+ for event in event_history:
819
+ event_type = event.get("type", "").lower()
820
+ timestamp = event.get("timestamp", 0)
821
+ amount = event.get("amount_usd", 0)
822
+
823
+ # Liquidity removal around price drops
824
+ if event_type in ("remove_liquidity", "burn_lp", "withdraw_lp"):
825
+ # Check if price dropped within 24h after removal
826
+ for period, change in price_changes.items():
827
+ if isinstance(change, (int, float)) and change < -20:
828
+ # More than 20% price drop
829
+ significant_drops.append({
830
+ "event_timestamp": timestamp,
831
+ "event_amount_usd": amount,
832
+ "price_change_pct": change,
833
+ "period": period,
834
+ })
835
+
836
+ if significant_drops:
837
+ total_drained = sum(d["event_amount_usd"] for d in significant_drops if d["event_amount_usd"])
838
+ return {
839
+ "severity": 0.8,
840
+ "description": (
841
+ f"{len(significant_drops)} liquidity removal(s) followed by "
842
+ f"significant price declines. Total drained: ${total_drained:,.0f}. "
843
+ "Pattern suggests coordinated liquidity removal preceding dump."
844
+ ),
845
+ "detail": {
846
+ "events": significant_drops,
847
+ "total_drained_usd": total_drained,
848
+ },
849
+ }
850
+
851
+ return None
852
+
853
+ def _check_single_sided_liquidity(
854
+ self, lp_data: dict[str, Any]
855
+ ) -> dict[str, Any] | None:
856
+ """Check for single-sided liquidity deposits."""
857
+ pairs = lp_data.get("pairs", [])
858
+ if not pairs:
859
+ return None
860
+
861
+ for pair in pairs:
862
+ token0 = pair.get("token0", "")
863
+ token1 = pair.get("token1", "")
864
+ reserve0 = pair.get("reserve0", 0)
865
+ reserve1 = pair.get("reserve1", 0)
866
+ pair_address = pair.get("address", "")
867
+
868
+ if reserve0 and reserve1:
869
+ ratio = reserve0 / reserve1 if reserve1 else 0
870
+ if ratio > 100 or ratio < 0.01:
871
+ return {
872
+ "severity": 0.7,
873
+ "description": (
874
+ f"Single-sided liquidity detected in pair {pair_address[:10]}... "
875
+ f"Token ratio is 1:{ratio:.0f}. "
876
+ "One side has negligible liquidity — "
877
+ "may prevent selling or enable price manipulation."
878
+ ),
879
+ "detail": {
880
+ "pair_address": pair_address,
881
+ "reserve0": reserve0,
882
+ "reserve1": reserve1,
883
+ "ratio": round(ratio, 2),
884
+ },
885
+ }
886
+
887
+ return None
888
+
889
+ def _determine_risk_level(
890
+ self, score: float, signals: list[LiquiditySignal]
891
+ ) -> LiquidityRisk:
892
+ """Convert score to risk level with signal overrides."""
893
+ # Check for critical signals
894
+ for s in signals:
895
+ if s.signal_type in (SignalType.FAKE_BURN, SignalType.LIQUIDITY_DRAIN):
896
+ if s.severity > 0.8:
897
+ return LiquidityRisk.CRITICAL
898
+
899
+ if score >= 70:
900
+ return LiquidityRisk.CRITICAL
901
+ if score >= 45:
902
+ return LiquidityRisk.HIGH
903
+ if score >= 20:
904
+ return LiquidityRisk.MEDIUM
905
+ if score >= 5:
906
+ return LiquidityRisk.LOW
907
+ return LiquidityRisk.SAFE
908
+
909
+ def _generate_summary(
910
+ self,
911
+ risk_level: LiquidityRisk,
912
+ score: float,
913
+ signals: list[LiquiditySignal],
914
+ ) -> str:
915
+ """Generate human-readable summary."""
916
+ signal_count = len(signals)
917
+ high_severity = sum(1 for s in signals if s.severity >= 0.7)
918
+
919
+ if risk_level == LiquidityRisk.CRITICAL:
920
+ return (
921
+ f"CRITICAL: {signal_count} scam signals detected "
922
+ f"({high_severity} high severity). "
923
+ "This token's liquidity is highly suspicious — "
924
+ "funds may be at immediate risk of being drained. AVOID."
925
+ )
926
+ if risk_level == LiquidityRisk.HIGH:
927
+ return (
928
+ f"HIGH RISK: {signal_count} liquidity anomalies found. "
929
+ f"Multiple red flags ({high_severity} critical). "
930
+ "Strong indicators of liquidity manipulation."
931
+ )
932
+ if risk_level == LiquidityRisk.MEDIUM:
933
+ return (
934
+ f"MEDIUM RISK: {signal_count} minor concerns detected. "
935
+ "Some liquidity practices are questionable. "
936
+ "Investigate further before committing funds."
937
+ )
938
+ if risk_level == LiquidityRisk.LOW:
939
+ return (
940
+ f"LOW RISK: {signal_count} minor signals found. "
941
+ "Liquidity appears mostly legitimate but has "
942
+ "minor areas of concern."
943
+ )
944
+ return (
945
+ "SAFE: No liquidity scam signals detected. "
946
+ "Liquidity appears properly locked and managed."
947
+ )
948
+
949
+
950
+ # ═══════════════════════════════════════════════════════════════════
951
+ # Helper Functions
952
+ # ═══════════════════════════════════════════════════════════════════
953
+
954
+
955
+ def format_report(report: LiquidityScamReport) -> str:
956
+ """Format a LiquidityScamReport as a human-readable string."""
957
+ lines: list[str] = [
958
+ f"🔍 Liquidity Scam Scan Report",
959
+ f"{'=' * 50}",
960
+ f"Token : {report.token_address[:20]}...",
961
+ f"Chain : {report.chain}",
962
+ f"Risk : {report.risk_level.value.upper()} ({report.risk_score:.1f}/100)",
963
+ f"Time : {datetime.fromtimestamp(report.scan_timestamp, tz=timezone.utc).isoformat()}",
964
+ f"{'=' * 50}",
965
+ ]
966
+
967
+ if report.signals:
968
+ lines.append(f"\n🚨 Signals ({len(report.signals)}):")
969
+ for s in report.signals:
970
+ emoji = {
971
+ SignalType.FAKE_BURN: "🔥",
972
+ SignalType.UNLOCKED_LP: "🔓",
973
+ SignalType.FLASH_LIQUIDITY: "⚡",
974
+ SignalType.CLAIM_MISMATCH: "📢",
975
+ SignalType.LP_CONCENTRATION: "🎯",
976
+ SignalType.LOW_LIQUIDITY_RATIO: "📉",
977
+ SignalType.SUSPICIOUS_TIMING: "⏰",
978
+ SignalType.SINGLE_SIDED: "⚖️",
979
+ SignalType.FAKE_LOCK: "🔐",
980
+ SignalType.TIMELOCK_BYPASS: "⏳",
981
+ SignalType.CROSS_CHAIN_MANIPULATION: "🌐",
982
+ SignalType.LIQUIDITY_DRAIN: "💧",
983
+ SignalType.UNKNOWN: "❓",
984
+ }.get(s.signal_type, "⚠️")
985
+ severity_label = (
986
+ "🔴" if s.severity >= 0.7
987
+ else "🟡" if s.severity >= 0.4
988
+ else "🟢"
989
+ )
990
+ lines.append(f" {emoji} {severity_label} [{s.signal_type.value}] {s.description[:120]}")
991
+
992
+ lines.append(f"\n📊 Score Breakdown:")
993
+ for key, value in sorted(report.score_breakdown.items(), key=lambda x: -x[1]):
994
+ if value > 0:
995
+ lines.append(f" {key.replace('_', ' ').title():30s} {value:5.1f} pts")
996
+
997
+ lines.append(f"\n💡 {report.summary}")
998
+ return "\n".join(lines)
999
+
1000
+
1001
+ # ═══════════════════════════════════════════════════════════════════
1002
+ # CLI Entry Point
1003
+ # ═══════════════════════════════════════════════════════════════════
1004
+
1005
+
1006
+ if __name__ == "__main__":
1007
+ import sys
1008
+
1009
+ async def main() -> None:
1010
+ detector = LiquidityScamDetector()
1011
+ token = sys.argv[1] if len(sys.argv) > 1 else "0x1234567890abcdef1234567890abcdef12345678"
1012
+ chain = sys.argv[2] if len(sys.argv) > 2 else "ethereum"
1013
+
1014
+ print(f"🔍 Scanning {token[:20]}... on {chain}")
1015
+ print()
1016
+
1017
+ report = await detector.analyze(
1018
+ token_address=token,
1019
+ chain=chain,
1020
+ lp_data={
1021
+ "total_liquidity_usd": 50000,
1022
+ "locked_liquidity_usd": 0,
1023
+ "unlocked_liquidity_usd": 50000,
1024
+ "burn_address": "0x0000000000000000000000000000000000000001",
1025
+ "deployer_address": "0x1234567890abcdef1234567890abcdef12345678",
1026
+ "pairs": [
1027
+ {
1028
+ "address": "0xpair1234567890abcdef1234567890abcdef1234",
1029
+ "token0": "0xabc...",
1030
+ "token1": "0xdef...",
1031
+ "reserve0": 1000000,
1032
+ "reserve1": 10,
1033
+ }
1034
+ ],
1035
+ },
1036
+ lock_data={
1037
+ "locker_contract": "",
1038
+ "unlock_timestamp": None,
1039
+ "total_locked_usd": 0,
1040
+ },
1041
+ holder_data=[
1042
+ {"address": "0x1234567890abcdef1234567890abcdef12345678", "percentage": 85.0},
1043
+ {"address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "percentage": 10.0},
1044
+ {"address": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "percentage": 5.0},
1045
+ ],
1046
+ market_data={
1047
+ "market_cap_usd": 2000000,
1048
+ },
1049
+ event_history=[
1050
+ {"type": "add_liquidity", "timestamp": time.time() - 3600 * 12, "amount_usd": 50000},
1051
+ {"type": "remove_liquidity", "timestamp": time.time() - 3600 * 2, "amount_usd": 45000},
1052
+ ],
1053
+ )
1054
+
1055
+ print(format_report(report))
1056
+
1057
+ asyncio.run(main())
backend/app/test_audit_report_validator.py CHANGED
@@ -3,7 +3,7 @@ Tests for Audit Report Validator (audit_report_validator.py)
3
  """
4
  import pytest
5
 
6
- from audit_report_validator import (
7
  AuditClaim,
8
  AuditReportValidator,
9
  AuditRisk,
 
3
  """
4
  import pytest
5
 
6
+ from app.audit_report_validator import (
7
  AuditClaim,
8
  AuditReportValidator,
9
  AuditRisk,
backend/app/test_liquidity_scam_detector.py ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for Fake Liquidity / Liquidity Lock Scam Detector (liquidity_scam_detector.py)
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import time
8
+ import unittest
9
+ from datetime import UTC, datetime
10
+
11
+ from app.liquidity_scam_detector import (
12
+ LiquidityRisk,
13
+ LiquidityScamDetector,
14
+ LiquidityScamReport,
15
+ LiquiditySignal,
16
+ SignalType,
17
+ TRUE_BURN_ADDRESS,
18
+ SUSPICIOUS_BURN_ADDRESSES,
19
+ format_report,
20
+ )
21
+
22
+
23
+ class TestLiquidityScamDetector(unittest.TestCase):
24
+ """Test suite for LiquidityScamDetector."""
25
+
26
+ def setUp(self):
27
+ self.detector = LiquidityScamDetector()
28
+
29
+ # ═══════════════════════════════════════════════════════════════════
30
+ # Smoke Tests — No data = safe
31
+ # ═══════════════════════════════════════════════════════════════════
32
+
33
+ def test_empty_data_returns_safe(self):
34
+ """No data = safe (no red flags)."""
35
+ report = asyncio.run(self.detector.analyze(
36
+ token_address="0x1234567890abcdef1234567890abcdef12345678",
37
+ chain="ethereum",
38
+ ))
39
+ self.assertEqual(report.risk_level, LiquidityRisk.SAFE)
40
+ self.assertEqual(report.risk_score, 0.0)
41
+ self.assertEqual(len(report.signals), 0)
42
+
43
+ def test_minimal_data_no_signals(self):
44
+ """Minimal data with safe-looking values."""
45
+ report = asyncio.run(self.detector.analyze(
46
+ token_address="0x1234567890abcdef1234567890abcdef12345678",
47
+ chain="ethereum",
48
+ lp_data={
49
+ "total_liquidity_usd": 1000000,
50
+ "burn_address": TRUE_BURN_ADDRESS,
51
+ },
52
+ lock_data={
53
+ "locker_contract": "0xefb9b72a0c3e2737f7d9e2e8e7e1f9e2d8e7e1f9",
54
+ "unlock_timestamp": time.time() + 86400 * 365, # 1 year from now
55
+ },
56
+ ))
57
+ self.assertEqual(report.risk_level, LiquidityRisk.SAFE)
58
+
59
+ # ═══════════════════════════════════════════════════════════════════
60
+ # Burn Address Tests
61
+ # ═══════════════════════════════════════════════════════════════════
62
+
63
+ def test_true_burn_address_no_signal(self):
64
+ """True burn address (0x...dead) should produce no signal."""
65
+ report = asyncio.run(self.detector.analyze(
66
+ token_address="0x1234",
67
+ chain="ethereum",
68
+ lp_data={"burn_address": TRUE_BURN_ADDRESS},
69
+ ))
70
+ fake_burn_signals = [
71
+ s for s in report.signals
72
+ if s.signal_type == SignalType.FAKE_BURN
73
+ ]
74
+ self.assertEqual(len(fake_burn_signals), 0)
75
+
76
+ def test_suspicious_burn_address_detected(self):
77
+ """Suspicious burn addresses should trigger a signal."""
78
+ for addr in SUSPICIOUS_BURN_ADDRESSES:
79
+ report = asyncio.run(self.detector.analyze(
80
+ token_address="0x1234",
81
+ chain="ethereum",
82
+ lp_data={"burn_address": addr},
83
+ ))
84
+ fake_burn_signals = [
85
+ s for s in report.signals
86
+ if s.signal_type == SignalType.FAKE_BURN
87
+ ]
88
+ self.assertEqual(
89
+ len(fake_burn_signals), 1,
90
+ f"Should detect {addr} as suspicious burn",
91
+ )
92
+
93
+ # ═══════════════════════════════════════════════════════════════════
94
+ # Lock Status Tests
95
+ # ═══════════════════════════════════════════════════════════════════
96
+
97
+ def test_no_lock_detected_high_risk(self):
98
+ """No lock contract = liquidity can be removed."""
99
+ report = asyncio.run(self.detector.analyze(
100
+ token_address="0x1234",
101
+ chain="ethereum",
102
+ lp_data={"total_liquidity_usd": 100000},
103
+ lock_data={},
104
+ ))
105
+ unlocked_signals = [
106
+ s for s in report.signals
107
+ if s.signal_type == SignalType.UNLOCKED_LP
108
+ ]
109
+ self.assertGreaterEqual(len(unlocked_signals), 1)
110
+ self.assertGreaterEqual(report.risk_score, 10)
111
+
112
+ def test_expiring_lock_high_risk(self):
113
+ """Lock expiring within hours should be critical."""
114
+ report = asyncio.run(self.detector.analyze(
115
+ token_address="0x1234",
116
+ chain="ethereum",
117
+ lp_data={"total_liquidity_usd": 100000},
118
+ lock_data={
119
+ "locker_contract": "0xefb9b72a0c3e2737f7d9e2e8e7e1f9e2d8e7e1f9",
120
+ "unlock_timestamp": time.time() + 3600, # 1 hour
121
+ },
122
+ ))
123
+ unlocked_signals = [
124
+ s for s in report.signals
125
+ if s.signal_type == SignalType.UNLOCKED_LP
126
+ ]
127
+ self.assertGreaterEqual(len(unlocked_signals), 1)
128
+ self.assertIn(report.risk_level, (LiquidityRisk.HIGH, LiquidityRisk.CRITICAL))
129
+
130
+ def test_long_lock_duration_low_risk(self):
131
+ """Lock with 1+ year duration should be low risk."""
132
+ report = asyncio.run(self.detector.analyze(
133
+ token_address="0x1234",
134
+ chain="ethereum",
135
+ lp_data={"total_liquidity_usd": 100000},
136
+ lock_data={
137
+ "locker_contract": "0xefb9b72a0c3e2737f7d9e2e8e7e1f9e2d8e7e1f9",
138
+ "unlock_timestamp": time.time() + 86400 * 400, # >1 year
139
+ },
140
+ ))
141
+ self.assertLessEqual(report.risk_score, 30)
142
+
143
+ # ═══════════════════════════════════════════════════════════════════
144
+ # Flash Liquidity Tests
145
+ # ═══════════════════════════════════════════════════════════════════
146
+
147
+ def test_flash_liquidity_detected(self):
148
+ """LP added and removed within hours should be flagged."""
149
+ now = time.time()
150
+ report = asyncio.run(self.detector.analyze(
151
+ token_address="0x1234",
152
+ chain="ethereum",
153
+ event_history=[
154
+ {"type": "add_liquidity", "timestamp": now - 7200, "amount_usd": 100000},
155
+ {"type": "remove_liquidity", "timestamp": now - 3600, "amount_usd": 95000},
156
+ ],
157
+ ))
158
+ flash_signals = [
159
+ s for s in report.signals
160
+ if s.signal_type == SignalType.FLASH_LIQUIDITY
161
+ ]
162
+ self.assertGreaterEqual(len(flash_signals), 1)
163
+ self.assertIn("flash", flash_signals[0].description.lower())
164
+
165
+ def test_stable_liquidity_no_flash_signal(self):
166
+ """Stable LP (no removal) should not trigger flash signal."""
167
+ now = time.time()
168
+ report = asyncio.run(self.detector.analyze(
169
+ token_address="0x1234",
170
+ chain="ethereum",
171
+ event_history=[
172
+ {"type": "add_liquidity", "timestamp": now - 86400 * 30, "amount_usd": 100000},
173
+ # No removal events
174
+ ],
175
+ ))
176
+ flash_signals = [
177
+ s for s in report.signals
178
+ if s.signal_type == SignalType.FLASH_LIQUIDITY
179
+ ]
180
+ self.assertEqual(len(flash_signals), 0)
181
+
182
+ # ═══════════════════════════════════════════════════════════════════
183
+ # Claim Mismatch Tests
184
+ # ═══════════════════════════════════════════════════════════════════
185
+
186
+ def test_claim_mismatch_detected(self):
187
+ """Claimed vs actual locked % discrepancy should be flagged."""
188
+ report = asyncio.run(self.detector.analyze(
189
+ token_address="0x1234",
190
+ chain="ethereum",
191
+ lp_data={
192
+ "claimed_locked_pct": 100,
193
+ "actual_locked_pct": 10,
194
+ "total_liquidity_usd": 100000,
195
+ },
196
+ ))
197
+ mismatch_signals = [
198
+ s for s in report.signals
199
+ if s.signal_type == SignalType.CLAIM_MISMATCH
200
+ ]
201
+ self.assertGreaterEqual(len(mismatch_signals), 1)
202
+
203
+ def test_consistent_claims_no_signal(self):
204
+ """Matching claims should not trigger."""
205
+ report = asyncio.run(self.detector.analyze(
206
+ token_address="0x1234",
207
+ chain="ethereum",
208
+ lp_data={
209
+ "claimed_locked_pct": 95,
210
+ "actual_locked_pct": 95,
211
+ },
212
+ ))
213
+ mismatch_signals = [
214
+ s for s in report.signals
215
+ if s.signal_type == SignalType.CLAIM_MISMATCH
216
+ ]
217
+ self.assertEqual(len(mismatch_signals), 0)
218
+
219
+ # ═══════════════════════════════════════════════════════════════════
220
+ # LP Concentration Tests
221
+ # ═══════════════════════════════════════════════════════════════════
222
+
223
+ def test_deployer_holds_majority_lp(self):
224
+ """Deployer holding >50% LP should be flagged."""
225
+ report = asyncio.run(self.detector.analyze(
226
+ token_address="0x1234",
227
+ chain="ethereum",
228
+ lp_data={
229
+ "deployer_address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
230
+ },
231
+ holder_data=[
232
+ {"address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1", "percentage": 80.0},
233
+ {"address": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "percentage": 20.0},
234
+ ],
235
+ ))
236
+ concentration_signals = [
237
+ s for s in report.signals
238
+ if s.signal_type == SignalType.LP_CONCENTRATION
239
+ ]
240
+ self.assertGreaterEqual(len(concentration_signals), 1)
241
+
242
+ def test_distributed_lp_no_signal(self):
243
+ """Well-distributed LP should not trigger."""
244
+ report = asyncio.run(self.detector.analyze(
245
+ token_address="0x1234",
246
+ chain="ethereum",
247
+ holder_data=[
248
+ {"address": "0xaaa", "percentage": 10.0},
249
+ {"address": "0xbbb", "percentage": 8.0},
250
+ {"address": "0xccc", "percentage": 7.0},
251
+ {"address": "0xddd", "percentage": 6.0},
252
+ ],
253
+ ))
254
+ concentration_signals = [
255
+ s for s in report.signals
256
+ if s.signal_type == SignalType.LP_CONCENTRATION
257
+ ]
258
+ self.assertEqual(len(concentration_signals), 0)
259
+
260
+ # ═══════════════════════════════════════════════════════════════════
261
+ # Liquidity Ratio Tests
262
+ # ═══════════════════════════════════════════════════════════════════
263
+
264
+ def test_low_liquidity_ratio_detected(self):
265
+ """Very low liquidity vs market cap should flag."""
266
+ report = asyncio.run(self.detector.analyze(
267
+ token_address="0x1234",
268
+ chain="ethereum",
269
+ lp_data={"total_liquidity_usd": 1000},
270
+ market_data={"market_cap_usd": 10000000},
271
+ ))
272
+ ratio_signals = [
273
+ s for s in report.signals
274
+ if s.signal_type == SignalType.LOW_LIQUIDITY_RATIO
275
+ ]
276
+ self.assertGreaterEqual(len(ratio_signals), 1)
277
+
278
+ def test_healthy_liquidity_ratio_no_signal(self):
279
+ """Healthy liquidity ratio should not trigger."""
280
+ report = asyncio.run(self.detector.analyze(
281
+ token_address="0x1234",
282
+ chain="ethereum",
283
+ lp_data={"total_liquidity_usd": 500000},
284
+ market_data={"market_cap_usd": 2000000},
285
+ ))
286
+ ratio_signals = [
287
+ s for s in report.signals
288
+ if s.signal_type == SignalType.LOW_LIQUIDITY_RATIO
289
+ ]
290
+ self.assertEqual(len(ratio_signals), 0)
291
+
292
+ # ═══════════════════════════════════════════════════════════════════
293
+ # Single-Sided Liquidity Tests
294
+ # ═══════════════════════════════════════════════════════════════════
295
+
296
+ def test_single_sided_liquidity_detected(self):
297
+ """Extreme reserve ratio should be flagged."""
298
+ report = asyncio.run(self.detector.analyze(
299
+ token_address="0x1234",
300
+ chain="ethereum",
301
+ lp_data={
302
+ "pairs": [
303
+ {
304
+ "address": "0xpair1234567890abcdef1234567890abcdef1234",
305
+ "token0": "0xabc",
306
+ "token1": "0xdef",
307
+ "reserve0": 1000000,
308
+ "reserve1": 10,
309
+ }
310
+ ],
311
+ },
312
+ ))
313
+ single_sided_signals = [
314
+ s for s in report.signals
315
+ if s.signal_type == SignalType.SINGLE_SIDED
316
+ ]
317
+ self.assertGreaterEqual(len(single_sided_signals), 1)
318
+
319
+ def test_balanced_pool_no_signal(self):
320
+ """Balanced pool should not trigger single-sided signal."""
321
+ report = asyncio.run(self.detector.analyze(
322
+ token_address="0x1234",
323
+ chain="ethereum",
324
+ lp_data={
325
+ "pairs": [
326
+ {
327
+ "address": "0xpair1234",
328
+ "token0": "0xabc",
329
+ "token1": "0xdef",
330
+ "reserve0": 1000000,
331
+ "reserve1": 950000,
332
+ }
333
+ ],
334
+ },
335
+ ))
336
+ single_sided_signals = [
337
+ s for s in report.signals
338
+ if s.signal_type == SignalType.SINGLE_SIDED
339
+ ]
340
+ self.assertEqual(len(single_sided_signals), 0)
341
+
342
+ # ═════════════════════════════���═════════════════════════════════════
343
+ # Signal Timing Tests
344
+ # ═══════════════════════════════════════════════════════════════════
345
+
346
+ def test_suspicious_timing_detected(self):
347
+ """Liquidity removal followed by price drop should be flagged."""
348
+ report = asyncio.run(self.detector.analyze(
349
+ token_address="0x1234",
350
+ chain="ethereum",
351
+ event_history=[
352
+ {"type": "remove_liquidity", "timestamp": time.time() - 86400 * 2, "amount_usd": 50000},
353
+ ],
354
+ market_data={
355
+ "price_changes": {"24h": -35.0},
356
+ },
357
+ ))
358
+ timing_signals = [
359
+ s for s in report.signals
360
+ if s.signal_type == SignalType.SUSPICIOUS_TIMING
361
+ ]
362
+ self.assertGreaterEqual(len(timing_signals), 1)
363
+
364
+ # ═══════════════════════════════════════════════════════════════════
365
+ # Integration / Multi-Signal Tests
366
+ # ═══════════════════════════════════════════════════════════════════
367
+
368
+ def test_multiple_red_flags_produce_high_score(self):
369
+ """Multiple scam indicators should produce high risk score."""
370
+ now = time.time()
371
+ report = asyncio.run(self.detector.analyze(
372
+ token_address="0x1234",
373
+ chain="ethereum",
374
+ lp_data={
375
+ "total_liquidity_usd": 50000,
376
+ "burn_address": "0x0000000000000000000000000000000000000001",
377
+ "deployer_address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
378
+ "pairs": [
379
+ {
380
+ "address": "0xpair1",
381
+ "token0": "0xabc",
382
+ "token1": "0xdef",
383
+ "reserve0": 990000,
384
+ "reserve1": 1000,
385
+ }
386
+ ],
387
+ },
388
+ lock_data={
389
+ "locker_contract": "",
390
+ "unlock_timestamp": None,
391
+ },
392
+ holder_data=[
393
+ {"address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1", "percentage": 85.0},
394
+ ],
395
+ market_data={"market_cap_usd": 10000000},
396
+ event_history=[
397
+ {"type": "add_liquidity", "timestamp": now - 3600 * 6, "amount_usd": 50000},
398
+ {"type": "remove_liquidity", "timestamp": now - 3600 * 2, "amount_usd": 40000},
399
+ ],
400
+ ))
401
+ self.assertGreater(len(report.signals), 2)
402
+ self.assertGreater(report.risk_score, 40)
403
+ self.assertIn(report.risk_level, (LiquidityRisk.HIGH, LiquidityRisk.CRITICAL))
404
+
405
+ # ═══════════════════════════════════════════════════════════════════
406
+ # Format Report Test
407
+ # ═══════════════════════════════════════════════════════════════════
408
+
409
+ def test_format_report(self):
410
+ """format_report should produce valid output without errors."""
411
+ report = LiquidityScamReport(
412
+ token_address="0x1234",
413
+ chain="ethereum",
414
+ risk_level=LiquidityRisk.MEDIUM,
415
+ risk_score=35.0,
416
+ signals=[
417
+ LiquiditySignal(
418
+ signal_type=SignalType.UNLOCKED_LP,
419
+ severity=0.8,
420
+ description="Test signal: LP not locked",
421
+ ),
422
+ ],
423
+ score_breakdown={"lock_status": 25.0},
424
+ summary="Test summary",
425
+ )
426
+ formatted = format_report(report)
427
+ self.assertIn("MEDIUM (35.0/100)", formatted)
428
+ self.assertIn("Test signal", formatted)
429
+ self.assertIn("Test summary", formatted)
430
+
431
+ def test_to_dict_serializable(self):
432
+ """to_dict() should produce JSON-serializable output."""
433
+ report = LiquidityScamReport(
434
+ token_address="0x1234",
435
+ chain="ethereum",
436
+ risk_level=LiquidityRisk.HIGH,
437
+ risk_score=65.0,
438
+ signals=[
439
+ LiquiditySignal(
440
+ signal_type=SignalType.FAKE_BURN,
441
+ severity=0.9,
442
+ description="Fake burn test",
443
+ detail={"address": "0xdead"},
444
+ ),
445
+ ],
446
+ score_breakdown={"fake_burn": 27.0},
447
+ summary="Test",
448
+ )
449
+ d = report.to_dict()
450
+ self.assertEqual(d["risk_level"], "high")
451
+ self.assertEqual(d["risk_score"], 65.0)
452
+ # Ensure it's JSON-serializable
453
+ json_str = json.dumps(d)
454
+ self.assertIn("Fake burn test", json_str)
455
+
456
+
457
+ if __name__ == "__main__":
458
+ unittest.main()
backend/app/test_wash_trading_detector.py CHANGED
@@ -6,7 +6,7 @@ import asyncio
6
  import unittest
7
  from unittest.mock import patch
8
 
9
- from wash_trading_detector import (
10
  CircularTrade,
11
  MatchedOrder,
12
  SelfTrade,
@@ -577,7 +577,7 @@ class TestEdgeCases(unittest.TestCase):
577
 
578
  def test_create_detector(self):
579
  """Factory function creates a valid detector."""
580
- from wash_trading_detector import create_detector
581
  d = create_detector()
582
  self.assertIsInstance(d, WashTradingDetector)
583
 
 
6
  import unittest
7
  from unittest.mock import patch
8
 
9
+ from app.wash_trading_detector import (
10
  CircularTrade,
11
  MatchedOrder,
12
  SelfTrade,
 
577
 
578
  def test_create_detector(self):
579
  """Factory function creates a valid detector."""
580
+ from app.wash_trading_detector import create_detector
581
  d = create_detector()
582
  self.assertIsInstance(d, WashTradingDetector)
583
 
backend/scripts/minimax_review_liquidity.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MiniMax review script for liquidity_scam_detector.py"""
2
+ import json
3
+ import os
4
+ import urllib.request
5
+ from pathlib import Path
6
+
7
+
8
+ def get_key() -> str | None:
9
+ """Try to find API key from env or .env files."""
10
+ key = os.environ.get("MINIMAX_API_KEY", "")
11
+ if key:
12
+ return key
13
+ # Check various .env files
14
+ for env_path in [
15
+ Path.home() / ".hermes" / ".env",
16
+ Path.cwd() / ".env",
17
+ Path.cwd().parent / ".env",
18
+ Path("/app/.env"),
19
+ ]:
20
+ if env_path.exists():
21
+ for line in env_path.read_text().splitlines():
22
+ if line.startswith("MINIMAX_API_KEY="):
23
+ key = line.split("=", 1)[1].strip().strip("'\"")
24
+ if key:
25
+ return key
26
+ return None
27
+
28
+
29
+ def main() -> None:
30
+ code = open("app/liquidity_scam_detector.py").read()[:5000]
31
+ key = get_key()
32
+ if not key:
33
+ print("NO_API_KEY_FOUND")
34
+ return
35
+
36
+ body = json.dumps({
37
+ "model": "MiniMax-Text-01",
38
+ "messages": [
39
+ {
40
+ "role": "system",
41
+ "content": "Review this Python code for bugs, security issues, and improvements. List top 3 issues found."
42
+ },
43
+ {"role": "user", "content": code}
44
+ ],
45
+ "max_tokens": 300
46
+ }).encode()
47
+
48
+ req = urllib.request.Request(
49
+ "https://api.minimax.io/v1/chat/completions",
50
+ data=body,
51
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
52
+ )
53
+ try:
54
+ resp = urllib.request.urlopen(req, timeout=30)
55
+ result = json.loads(resp.read())
56
+ print(result["choices"][0]["message"]["content"])
57
+ except Exception as e:
58
+ print(f"API_ERROR: {e}")
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()