293 lines
10 KiB
Python
293 lines
10 KiB
Python
"""
|
|
Tests for Token Clone Scanner (clone_scanner.py)
|
|
|
|
Tests the core logic: string similarity, name similarity scoring,
|
|
risk labeling, data models, and fast_check surrogate.
|
|
Network tests are skipped in offline mode.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
# Add parent to path
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
from app.clone_scanner import (
|
|
CloneReport,
|
|
CloneScanner,
|
|
SimilarToken,
|
|
name_similarity_score,
|
|
string_similarity,
|
|
)
|
|
|
|
|
|
class TestStringHelpers(unittest.TestCase):
|
|
"""Core string similarity utilities."""
|
|
|
|
def test_identical_strings(self):
|
|
self.assertEqual(string_similarity("Pepe", "Pepe"), 1.0)
|
|
|
|
def test_case_insensitive(self):
|
|
self.assertAlmostEqual(string_similarity("Pepe", "pepe"), 1.0)
|
|
|
|
def test_completely_different(self):
|
|
self.assertAlmostEqual(string_similarity("Pepe", "Bitcoin"), 0.0, places=1)
|
|
|
|
def test_partial_match(self):
|
|
sim = string_similarity("Pepe", "Pepe 2.0")
|
|
self.assertGreater(sim, 0.3)
|
|
self.assertLess(sim, 1.0)
|
|
|
|
def test_empty_strings(self):
|
|
self.assertEqual(string_similarity("", "Pepe"), 0.0)
|
|
self.assertEqual(string_similarity("Pepe", ""), 0.0)
|
|
self.assertEqual(string_similarity("", ""), 0.0)
|
|
|
|
|
|
class TestNameSimilarity(unittest.TestCase):
|
|
"""Name similarity scoring with clone patterns."""
|
|
|
|
def test_identical_names(self):
|
|
self.assertEqual(name_similarity_score("Pepe", "Pepe"), 1.0)
|
|
|
|
def test_prefix_clone(self):
|
|
"""'Baby Pepe' should score high vs 'Pepe'."""
|
|
score = name_similarity_score("Baby Pepe", "Pepe")
|
|
self.assertGreaterEqual(score, 0.65)
|
|
|
|
def test_suffix_clone(self):
|
|
"""'Pepe v2' should score very high vs 'Pepe'."""
|
|
score = name_similarity_score("Pepe v2", "Pepe")
|
|
self.assertGreaterEqual(score, 0.85)
|
|
|
|
def test_known_v2_pattern(self):
|
|
score = name_similarity_score("SafeMoon", "SafeMoon 2.0")
|
|
self.assertGreaterEqual(score, 0.85)
|
|
|
|
def test_different_tokens(self):
|
|
score = name_similarity_score("Bitcoin", "Pepe")
|
|
self.assertLess(score, 0.3)
|
|
|
|
|
|
class TestCloneReport(unittest.TestCase):
|
|
"""CloneReport data model and helpers."""
|
|
|
|
def test_to_dict_structure(self):
|
|
report = CloneReport(
|
|
token_address="0xabc123",
|
|
chain="ethereum",
|
|
name="TestToken",
|
|
symbol="TEST",
|
|
clone_score=45.5,
|
|
risk_label="medium",
|
|
similar_tokens=[
|
|
SimilarToken(
|
|
address="0xdef456",
|
|
chain="ethereum",
|
|
name="TestToken Clone",
|
|
symbol="TSTC",
|
|
name_similarity=0.85,
|
|
symbol_similarity=0.6,
|
|
)
|
|
],
|
|
matched_keywords=["pepe"],
|
|
unverified_contract=True,
|
|
same_deployer_other_tokens=12,
|
|
)
|
|
d = report.to_dict()
|
|
self.assertEqual(d["token_address"], "0xabc123")
|
|
self.assertEqual(d["clone_score"], 45.5)
|
|
self.assertEqual(d["risk_label"], "medium")
|
|
self.assertEqual(len(d["similar_tokens"]), 1)
|
|
self.assertEqual(d["matched_keywords"], ["pepe"])
|
|
self.assertTrue(d["unverified_contract"])
|
|
self.assertEqual(d["same_deployer_other_tokens"], 12)
|
|
|
|
# Check signals sub-dict
|
|
self.assertIn("bytecode_similarity", d["signals"])
|
|
self.assertIn("name_similarity", d["signals"])
|
|
self.assertIn("deployer_risk", d["signals"])
|
|
self.assertIn("metadata_risk", d["signals"])
|
|
|
|
def test_summary_format(self):
|
|
report = CloneReport(
|
|
token_address="0xabc123def4567890123456789012345678901234",
|
|
chain="ethereum",
|
|
name="ShadyToken",
|
|
symbol="SHADY",
|
|
clone_score=72.3,
|
|
risk_label="high",
|
|
similar_tokens=[
|
|
SimilarToken(
|
|
address="0xdead",
|
|
chain="ethereum",
|
|
name="RealToken",
|
|
symbol="REAL",
|
|
name_similarity=0.9,
|
|
)
|
|
],
|
|
matched_keywords=["pepe", "moon"],
|
|
unverified_contract=True,
|
|
)
|
|
summary = report.summary()
|
|
self.assertIn("HIGH", summary)
|
|
self.assertIn("72", summary)
|
|
self.assertIn("ShadyToken", summary)
|
|
self.assertIn("1 similar tokens", summary)
|
|
|
|
def test_risk_label_none(self):
|
|
report = CloneReport(token_address="0xaaa", chain="ethereum", clone_score=0.0)
|
|
scanner = CloneScanner()
|
|
report.risk_label = scanner._label_risk(0.0)
|
|
self.assertEqual(report.risk_label, "none")
|
|
|
|
def test_risk_label_critical(self):
|
|
report = CloneReport(token_address="0xaaa", chain="ethereum", clone_score=85.0)
|
|
scanner = CloneScanner()
|
|
report.risk_label = scanner._label_risk(85.0)
|
|
self.assertEqual(report.risk_label, "critical")
|
|
|
|
def test_risk_label_brackets(self):
|
|
report = CloneReport(token_address="0xaaa", chain="ethereum", clone_score=25.0)
|
|
scanner = CloneScanner()
|
|
report.risk_label = scanner._label_risk(25.0)
|
|
self.assertEqual(report.risk_label, "medium")
|
|
|
|
|
|
class TestSimilarToken(unittest.TestCase):
|
|
"""SimilarToken helper properties."""
|
|
|
|
def test_overall_similarity(self):
|
|
t = SimilarToken(
|
|
address="0xabc",
|
|
chain="ethereum",
|
|
name="TokenA",
|
|
symbol="TKA",
|
|
name_similarity=0.9,
|
|
symbol_similarity=0.5,
|
|
)
|
|
self.assertEqual(t.overall_similarity, 0.9)
|
|
|
|
def test_overall_uses_symbol_when_higher(self):
|
|
t = SimilarToken(
|
|
address="0xabc",
|
|
chain="ethereum",
|
|
name="TokenA",
|
|
symbol="TKA",
|
|
name_similarity=0.4,
|
|
symbol_similarity=0.95,
|
|
)
|
|
self.assertEqual(t.overall_similarity, 0.95)
|
|
|
|
|
|
class TestScannerScoring(unittest.TestCase):
|
|
"""Unit tests for scoring methods (no network)."""
|
|
|
|
def setUp(self):
|
|
self.scanner = CloneScanner()
|
|
|
|
def test_score_bytecode_unverified(self):
|
|
score = self.scanner._score_bytecode_risk(True, {})
|
|
self.assertEqual(score, 40) # 40 for unverified
|
|
|
|
def test_score_bytecode_verified(self):
|
|
score = self.scanner._score_bytecode_risk(False, {"other_unverified": 0})
|
|
self.assertEqual(score, 0.0)
|
|
|
|
def test_score_bytecode_mass_deployer(self):
|
|
score = self.scanner._score_bytecode_risk(True, {"other_unverified": 10, "other_tokens": 30})
|
|
# 40 (unverified) + 20 (many unverified) + 15 (mass deployer) = 75
|
|
self.assertEqual(score, 75)
|
|
|
|
def test_score_name_no_keywords(self):
|
|
score = self.scanner._score_name_similarity([], [])
|
|
self.assertEqual(score, 0.0)
|
|
|
|
def test_score_name_with_keywords(self):
|
|
score = self.scanner._score_name_similarity([], ["pepe", "moon"])
|
|
self.assertEqual(score, 25)
|
|
|
|
def test_score_name_with_high_similarity(self):
|
|
t = SimilarToken("0x1", "eth", "Clone", "CLO", name_similarity=0.95)
|
|
score = self.scanner._score_name_similarity([t], [])
|
|
# 1 high-sim match = 20
|
|
self.assertEqual(score, 20)
|
|
|
|
def test_score_deployer_low(self):
|
|
score = self.scanner._score_deployer_risk({"other_tokens": 2})
|
|
self.assertEqual(score, 0.0)
|
|
|
|
def test_score_deployer_medium(self):
|
|
score = self.scanner._score_deployer_risk({"other_tokens": 10})
|
|
self.assertEqual(score, 15)
|
|
|
|
def test_score_deployer_high(self):
|
|
score = self.scanner._score_deployer_risk({"other_tokens": 30})
|
|
self.assertEqual(score, 30)
|
|
|
|
def test_score_deployer_critical(self):
|
|
score = self.scanner._score_deployer_risk({"other_tokens": 100})
|
|
self.assertEqual(score, 40)
|
|
|
|
def test_score_deployer_clone_flag(self):
|
|
score = self.scanner._score_deployer_risk({"other_tokens": 5, "clone_deployer": True})
|
|
# 5 other tokens = no bonus (needs >20), +30 clone flag = 30
|
|
self.assertEqual(score, 30)
|
|
|
|
def test_score_metadata_missing(self):
|
|
score = self.scanner._score_metadata_risk({}, [], [])
|
|
# Empty dict = no name or symbol = 20+20 = 40
|
|
self.assertEqual(score, 40)
|
|
|
|
def test_score_metadata_with_social(self):
|
|
score = self.scanner._score_metadata_risk({"name": "T", "symbol": "S", "social": {"twitter": "@x"}}, [], [])
|
|
self.assertEqual(score, 10) # missing website
|
|
|
|
def test_score_metadata_full(self):
|
|
score = self.scanner._score_metadata_risk(
|
|
{
|
|
"name": "Test",
|
|
"symbol": "TST",
|
|
"social": {"twitter": "@x", "telegram": "@g"},
|
|
"website": "https://t.com",
|
|
},
|
|
[],
|
|
[],
|
|
)
|
|
self.assertEqual(score, 0.0)
|
|
|
|
def test_compute_composite_score(self):
|
|
report = CloneReport(
|
|
token_address="0x1",
|
|
chain="eth",
|
|
bytecode_similarity_score=100,
|
|
name_similarity_score=50,
|
|
deployer_risk_score=0,
|
|
metadata_risk_score=0,
|
|
)
|
|
# 0.25*100 + 0.35*50 + 0.25*0 + 0.15*0 = 25 + 17.5 = 42.5
|
|
self.assertEqual(self.scanner._compute_clone_score(report), 42.5)
|
|
|
|
def test_well_known_by_name(self):
|
|
self.assertTrue(self.scanner._is_well_known("Bitcoin", "BTC"))
|
|
self.assertTrue(self.scanner._is_well_known("Ethereum", "ETH"))
|
|
|
|
def test_well_known_by_symbol(self):
|
|
self.assertTrue(self.scanner._is_well_known("Random", "ETH"))
|
|
|
|
def test_not_well_known(self):
|
|
self.assertFalse(self.scanner._is_well_known("ShadyCoin", "SHADY"))
|
|
|
|
def test_valid_evm_address(self):
|
|
self.assertTrue(self.scanner._validate_address("0x1234567890abcdef1234567890abcdef12345678", "ethereum"))
|
|
|
|
def test_valid_solana_address(self):
|
|
self.assertTrue(self.scanner._validate_address("So11111111111111111111111111111111111111112", "solana"))
|
|
|
|
def test_invalid_address(self):
|
|
self.assertFalse(self.scanner._validate_address("not-an-address", "ethereum"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|