| """ |
| 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) |
| |
| 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) |
| |
| 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() |
|
|