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 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 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(
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 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(
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 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(
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 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()
|