- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""
|
|
Tests for Launch Fairness Analyzer.
|
|
"""
|
|
|
|
import unittest
|
|
|
|
from app.launch_fairness_analyzer import (
|
|
FairnessSignal,
|
|
FairnessSignalResult,
|
|
LaunchFairnessResult,
|
|
Severity,
|
|
_detect_bot_activity,
|
|
_detect_bundled_launch,
|
|
_detect_concentrated_holders,
|
|
_detect_lp_manipulation,
|
|
_detect_presale_concentration,
|
|
_detect_rapid_dump,
|
|
_detect_sniped_distribution,
|
|
_risk_level_from_score,
|
|
_severity_from_score,
|
|
analyze_launch_fairness,
|
|
)
|
|
|
|
|
|
class TestLaunchFairnessAnalyzer(unittest.TestCase):
|
|
"""Test the main analyze_launch_fairness function."""
|
|
|
|
def test_basic_analysis(self) -> None:
|
|
"""Should produce a valid fairness analysis from a token address."""
|
|
import asyncio
|
|
|
|
result = asyncio.run(
|
|
analyze_launch_fairness(
|
|
"0x1234567890abcdef1234567890abcdef12345678",
|
|
chain="ethereum",
|
|
simulate_data=True,
|
|
)
|
|
)
|
|
self.assertIn("token_address", result)
|
|
self.assertEqual(
|
|
result["token_address"],
|
|
"0x1234567890abcdef1234567890abcdef12345678",
|
|
)
|
|
self.assertIn("fairness_score", result)
|
|
self.assertIn("risk_level", result)
|
|
self.assertIn("signals", result)
|
|
self.assertIn("summary", result)
|
|
|
|
def test_solana_address(self) -> None:
|
|
"""Solana address format should be detected."""
|
|
import asyncio
|
|
|
|
sol_addr = "AbCdEf1234567890AbCdEf1234567890AbCdEf1234567890AbCdEf1234567890"
|
|
result = asyncio.run(analyze_launch_fairness(sol_addr, chain="auto", simulate_data=True))
|
|
self.assertIn("solana", result["chain"])
|
|
|
|
def test_invalid_address(self) -> None:
|
|
"""Invalid address should produce warnings but not crash."""
|
|
import asyncio
|
|
|
|
result = asyncio.run(analyze_launch_fairness("invalid!", chain="ethereum", simulate_data=True))
|
|
self.assertIn("warnings", result)
|
|
self.assertTrue(len(result["warnings"]) > 0)
|
|
|
|
def test_analysis_has_signals(self) -> None:
|
|
"""Analysis should return all signal types."""
|
|
import asyncio
|
|
|
|
result = asyncio.run(
|
|
analyze_launch_fairness(
|
|
"0xabcdef1234567890abcdef1234567890abcdef12",
|
|
chain="ethereum",
|
|
simulate_data=True,
|
|
)
|
|
)
|
|
signal_names = {s["signal"] for s in result["signals"]}
|
|
expected_signals = {
|
|
"sniped_distribution",
|
|
"bundled_launch",
|
|
"concentrated_top_holders",
|
|
"lp_manipulation",
|
|
"bot_activity",
|
|
"presale_concentration",
|
|
"rapid_dump_signal",
|
|
}
|
|
self.assertEqual(signal_names, expected_signals)
|
|
|
|
def test_analysis_time_is_measured(self) -> None:
|
|
"""Analysis time should be a positive number."""
|
|
import asyncio
|
|
|
|
result = asyncio.run(
|
|
analyze_launch_fairness(
|
|
"0xdead000000000000000000000000000000000000",
|
|
chain="ethereum",
|
|
simulate_data=True,
|
|
)
|
|
)
|
|
self.assertGreater(result["analysis_time_ms"], 0)
|
|
|
|
|
|
class TestSignalDetection(unittest.TestCase):
|
|
"""Test individual signal detectors."""
|
|
|
|
def test_sniped_detection_no_data(self) -> None:
|
|
"""No data should return undetected."""
|
|
result = _detect_sniped_distribution("0xabc", "ethereum")
|
|
self.assertFalse(result.detected)
|
|
self.assertIn("No transaction data", result.details)
|
|
|
|
def test_sniped_detection_with_data(self) -> None:
|
|
"""Multiple same-block buys should trigger sniper detection."""
|
|
txs = [
|
|
{
|
|
"from": f"0x{i:040x}",
|
|
"to": "0xtoken",
|
|
"block_number": 1,
|
|
"amount_usd": 1000,
|
|
"type": "buy",
|
|
}
|
|
for i in range(5)
|
|
]
|
|
result = _detect_sniped_distribution("0xabc", "ethereum", txs)
|
|
self.assertTrue(result.detected)
|
|
self.assertGreaterEqual(result.score, 0.4)
|
|
|
|
def test_bundle_detection_no_data(self) -> None:
|
|
"""No data should return undetected."""
|
|
result = _detect_bundled_launch("0xabc", "ethereum", [])
|
|
self.assertFalse(result.detected)
|
|
self.assertIn("Insufficient transaction data", result.details)
|
|
|
|
def test_bundle_detection_with_funders(self) -> None:
|
|
"""Multiple wallets funded by same source should trigger bundling."""
|
|
txs = [
|
|
{
|
|
"from": f"0x{i:040x}",
|
|
"to": "0xtoken",
|
|
"funded_by": "0xfunder123",
|
|
"amount_usd": 5000,
|
|
"block_number": 1,
|
|
}
|
|
for i in range(5)
|
|
]
|
|
result = _detect_bundled_launch("0xabc", "ethereum", txs)
|
|
self.assertTrue(result.detected)
|
|
self.assertGreaterEqual(result.score, 0.2)
|
|
|
|
def test_concentration_high(self) -> None:
|
|
"""90%+ concentration should be critical."""
|
|
holders = [{"address": f"0x{i:040x}", "balance": 100_000_000} for i in range(3)]
|
|
holders.append({"address": "0xsmall", "balance": 1_000_000})
|
|
result = _detect_concentrated_holders(holders)
|
|
# Top 3 hold 300M out of 301M = ~99.7%
|
|
self.assertTrue(result.detected)
|
|
self.assertEqual(result.severity, Severity.CRITICAL)
|
|
|
|
def test_concentration_low(self) -> None:
|
|
"""Low concentration should not be flagged."""
|
|
holders = [{"address": f"0x{i:040x}", "balance": 1_000_000} for i in range(100)]
|
|
result = _detect_concentrated_holders(holders)
|
|
# Top 10 hold 10M out of 100M = 10%
|
|
self.assertFalse(result.detected)
|
|
|
|
def test_concentration_no_data(self) -> None:
|
|
"""No holder data should return undetected."""
|
|
result = _detect_concentrated_holders([])
|
|
self.assertFalse(result.detected)
|
|
|
|
def test_lp_manipulation_delayed(self) -> None:
|
|
"""Delayed LP addition should be flagged."""
|
|
txs = [
|
|
{
|
|
"from": "0xbuyer",
|
|
"to": "0xtoken",
|
|
"block_number": 5,
|
|
"type": "buy",
|
|
"amount_usd": 100,
|
|
}
|
|
]
|
|
lp_data = {"add_delay_blocks": 500, "lp_token_concentration": 0.0}
|
|
result = _detect_lp_manipulation(lp_data, txs)
|
|
self.assertTrue(result.detected)
|
|
self.assertGreaterEqual(result.score, 0.4)
|
|
|
|
def test_lp_manipulation_removed(self) -> None:
|
|
"""LP removed should be flagged as high severity."""
|
|
txs = [
|
|
{
|
|
"from": "0xevil",
|
|
"to": "0xtoken",
|
|
"type": "remove_liquidity",
|
|
"block_number": 10,
|
|
"amount_usd": 50000,
|
|
}
|
|
]
|
|
result = _detect_lp_manipulation({}, txs)
|
|
self.assertTrue(result.detected)
|
|
self.assertGreaterEqual(result.score, 0.4)
|
|
|
|
def test_bot_detection_high_tx(self) -> None:
|
|
"""High transaction count from same wallet should be bot flagged."""
|
|
txs = [
|
|
{
|
|
"from": "0xbotwallet",
|
|
"to": "0xother",
|
|
"type": "swap",
|
|
"amount_usd": 100,
|
|
"timestamp": 1700000000 + i,
|
|
"gas_price_gwei": 50,
|
|
}
|
|
for i in range(10)
|
|
]
|
|
result = _detect_bot_activity(txs)
|
|
self.assertTrue(result.detected)
|
|
|
|
def test_bot_detection_no_data(self) -> None:
|
|
"""No data should return undetected."""
|
|
result = _detect_bot_activity([])
|
|
self.assertFalse(result.detected)
|
|
self.assertIn("Insufficient transaction data", result.details)
|
|
|
|
def test_presale_concentration_high(self) -> None:
|
|
"""50%+ presale should be critical."""
|
|
presale = {
|
|
"presale_allocation_pct": 60.0,
|
|
"participant_count": 100,
|
|
"insider_allocation_pct": 5.0,
|
|
"vc_allocation_pct": 10.0,
|
|
}
|
|
result = _detect_presale_concentration(presale)
|
|
self.assertTrue(result.detected)
|
|
self.assertEqual(result.severity, Severity.CRITICAL)
|
|
|
|
def test_presale_concentration_missing(self) -> None:
|
|
"""No presale data should return undetected."""
|
|
result = _detect_presale_concentration(None)
|
|
self.assertFalse(result.detected)
|
|
|
|
def test_rapid_dump_no_sells(self) -> None:
|
|
"""No early sells should return undetected."""
|
|
txs = [
|
|
{
|
|
"from": "0xbuyer",
|
|
"to": "0xtoken",
|
|
"type": "buy",
|
|
"block_number": 1,
|
|
"amount_usd": 1000,
|
|
}
|
|
]
|
|
result = _detect_rapid_dump(txs)
|
|
self.assertFalse(result.detected)
|
|
|
|
def test_rapid_dump_detected(self) -> None:
|
|
"""Large early sells should be flagged."""
|
|
txs = [
|
|
{
|
|
"from": f"0x{i:040x}",
|
|
"to": "0xtoken",
|
|
"type": "sell",
|
|
"block_number": 3,
|
|
"amount_usd": 50000,
|
|
}
|
|
for i in range(3)
|
|
]
|
|
result = _detect_rapid_dump(txs)
|
|
self.assertTrue(result.detected)
|
|
self.assertGreaterEqual(result.score, 0.6)
|
|
|
|
|
|
class TestScoring(unittest.TestCase):
|
|
"""Test scoring utilities."""
|
|
|
|
def test_severity_mapping(self) -> None:
|
|
self.assertEqual(_severity_from_score(0.9), Severity.CRITICAL)
|
|
self.assertEqual(_severity_from_score(0.7), Severity.HIGH)
|
|
self.assertEqual(_severity_from_score(0.5), Severity.MODERATE)
|
|
self.assertEqual(_severity_from_score(0.3), Severity.LOW)
|
|
self.assertEqual(_severity_from_score(0.1), Severity.NONE)
|
|
|
|
def test_risk_level_mapping(self) -> None:
|
|
self.assertEqual(_risk_level_from_score(90), "low")
|
|
self.assertEqual(_risk_level_from_score(70), "medium")
|
|
self.assertEqual(_risk_level_from_score(50), "high")
|
|
self.assertEqual(_risk_level_from_score(30), "critical")
|
|
|
|
|
|
class TestSerialization(unittest.TestCase):
|
|
"""Test serialization of result objects."""
|
|
|
|
def test_fairness_signal_result_to_dict(self) -> None:
|
|
signal = FairnessSignalResult(
|
|
signal=FairnessSignal.SNIPED_DISTRIBUTION,
|
|
detected=True,
|
|
severity=Severity.HIGH,
|
|
score=0.75,
|
|
details="Sniping detected",
|
|
evidence=["Block 1: 5 wallets bought"],
|
|
)
|
|
d = signal.to_dict()
|
|
self.assertEqual(d["signal"], "sniped_distribution")
|
|
self.assertTrue(d["detected"])
|
|
self.assertEqual(d["severity"], "high")
|
|
self.assertEqual(d["score"], 0.75)
|
|
|
|
def test_launch_fairness_result_to_dict(self) -> None:
|
|
result = LaunchFairnessResult(token_address="0xabc", chain="ethereum")
|
|
result.fairness_score = 45.0
|
|
result.risk_level = "high"
|
|
result.summary = "High risk - multiple signals"
|
|
result.signals = [
|
|
FairnessSignalResult(
|
|
signal=FairnessSignal.BUNDLED_LAUNCH,
|
|
detected=True,
|
|
score=0.6,
|
|
)
|
|
]
|
|
d = result.to_dict()
|
|
self.assertEqual(d["token_address"], "0xabc")
|
|
self.assertEqual(d["chain"], "ethereum")
|
|
self.assertEqual(d["fairness_score"], 45.0)
|
|
self.assertEqual(d["risk_level"], "high")
|
|
self.assertEqual(len(d["signals"]), 1)
|
|
|
|
def test_empty_result_serialization(self) -> None:
|
|
result = LaunchFairnessResult(token_address="0xempty", chain="ethereum")
|
|
d = result.to_dict()
|
|
self.assertEqual(d["token_address"], "0xempty")
|
|
self.assertEqual(d["fairness_score"], 100.0)
|
|
self.assertIn("summary", d)
|
|
self.assertIn("analysis_time_ms", d)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|