438 lines
15 KiB
Python
438 lines
15 KiB
Python
"""
|
|
Tests for Cross-Chain Fund Trace.
|
|
"""
|
|
|
|
import unittest
|
|
from datetime import UTC, datetime
|
|
|
|
from app.cross_chain_trace import (
|
|
ConfidenceLevel,
|
|
FundTraceResult,
|
|
TraceHop,
|
|
TraceHopType,
|
|
_compute_confidence,
|
|
_compute_suspiciousness,
|
|
_detect_chain,
|
|
_generate_summary,
|
|
_is_bridge,
|
|
_is_burn_address,
|
|
_is_exchange,
|
|
_is_mixer,
|
|
_normalize_address,
|
|
trace_funds,
|
|
)
|
|
|
|
|
|
class TestTraceFunds(unittest.TestCase):
|
|
"""Test the main trace_funds function."""
|
|
|
|
def test_trace_from_ethereum_wallet(self) -> None:
|
|
"""Should produce a valid trace from an Ethereum address."""
|
|
import asyncio
|
|
|
|
result = asyncio.run(trace_funds("0x1234567890abcdef1234567890abcdef12345678", "ethereum"))
|
|
self.assertIn("source_address", result)
|
|
self.assertEqual(result["source_address"], "0x1234567890abcdef1234567890abcdef12345678")
|
|
self.assertIn("summary", result)
|
|
self.assertIn("confidence_score", result)
|
|
self.assertIn("suspicious_score", result)
|
|
|
|
def test_trace_from_mixer(self) -> None:
|
|
"""A mixer address should be flagged."""
|
|
import asyncio
|
|
|
|
result = asyncio.run(trace_funds("0x12d66f87a04a9e220743712ce6d9bb1b5616b8fc", "ethereum"))
|
|
self.assertGreaterEqual(result["suspicious_score"], 0)
|
|
# Mixer detection
|
|
mixer_warnings = [w for w in result["warnings"] if "mixer" in w.lower()]
|
|
self.assertTrue(len(mixer_warnings) > 0 or result["source_of_funds"] != "unknown")
|
|
|
|
def test_trace_from_bridge(self) -> None:
|
|
"""A known bridge address should be identified."""
|
|
import asyncio
|
|
|
|
# Stargate bridge address
|
|
result = asyncio.run(trace_funds("0x8731d54e9d02c286767d56ac03e8037c07e01e98", "ethereum"))
|
|
self.assertIn("bridge", result["source_of_funds"].lower())
|
|
|
|
def test_trace_from_exchange(self) -> None:
|
|
"""A known exchange address should be identified."""
|
|
import asyncio
|
|
|
|
# Binance hot wallet
|
|
result = asyncio.run(trace_funds("0x3f5ce5fbfe3e9af3971dd833d26ba9b5c936f0be", "ethereum"))
|
|
self.assertIn("cex", result["source_of_funds"].lower())
|
|
|
|
def test_trace_deep_mode(self) -> None:
|
|
"""Deep mode should add more chains and warnings."""
|
|
import asyncio
|
|
|
|
result = asyncio.run(trace_funds("0xabc123", "ethereum", depth="deep"))
|
|
self.assertGreaterEqual(len(result["chains_used"]), 2)
|
|
deep_warnings = [w for w in result["warnings"] if "deep" in w.lower()]
|
|
self.assertTrue(len(deep_warnings) > 0)
|
|
|
|
def test_trace_solana_address(self) -> None:
|
|
"""Solana address format should be detected."""
|
|
import asyncio
|
|
|
|
sol_addr = "AbCdEf1234567890AbCdEf1234567890AbCdEf1234567890AbCdEf1234567890"
|
|
result = asyncio.run(trace_funds(sol_addr, "auto"))
|
|
self.assertIn("solana", result["chains_used"])
|
|
|
|
def test_trace_quick_mode(self) -> None:
|
|
"""Quick mode should note limited depth."""
|
|
import asyncio
|
|
|
|
result = asyncio.run(trace_funds("0xdead000000000000000000000000000000000000", "ethereum", depth="quick"))
|
|
quick_warnings = [w for w in result["warnings"] if "quick" in w.lower()]
|
|
self.assertTrue(len(quick_warnings) > 0)
|
|
|
|
def test_max_hops_limit(self) -> None:
|
|
"""Should respect max_hops limit."""
|
|
import asyncio
|
|
|
|
result = asyncio.run(trace_funds("0xtest", "ethereum", max_hops=5))
|
|
self.assertLessEqual(len(result["trace_path"]), 5)
|
|
|
|
|
|
class TestAddressPatterns(unittest.TestCase):
|
|
"""Test address pattern matching."""
|
|
|
|
def test_ethereum_detection(self) -> None:
|
|
self.assertEqual(_detect_chain("0x1234567890abcdef1234567890abcdef12345678"), "ethereum")
|
|
|
|
def test_solana_detection(self) -> None:
|
|
self.assertEqual(
|
|
_detect_chain("AbCdEf1234567890AbCdEf1234567890AbCdEf1234567890AbCdEf1234567890"),
|
|
"solana",
|
|
)
|
|
|
|
def test_unknown_detection(self) -> None:
|
|
self.assertEqual(_detect_chain("short"), "unknown")
|
|
self.assertEqual(_detect_chain(""), "unknown")
|
|
|
|
def test_normalize(self) -> None:
|
|
self.assertEqual(_normalize_address("0xABC123"), "0xabc123")
|
|
self.assertEqual(_normalize_address(" 0xABC "), "0xabc")
|
|
self.assertEqual(_normalize_address(""), "")
|
|
|
|
def test_bridge_detection(self) -> None:
|
|
is_b, name = _is_bridge("0x1a44076050125825947e16f8b9af3a1b524c09ce")
|
|
self.assertTrue(is_b)
|
|
self.assertEqual(name, "layerzero")
|
|
|
|
is_b, name = _is_bridge("0x8731d54e9d02c286767d56ac03e8037c07e01e98")
|
|
self.assertTrue(is_b)
|
|
self.assertEqual(name, "stargate")
|
|
|
|
is_b, name = _is_bridge("0x0000000000000000000000000000000000000000")
|
|
self.assertFalse(is_b)
|
|
|
|
def test_mixer_detection(self) -> None:
|
|
is_m, name = _is_mixer("0x12d66f87a04a9e220743712ce6d9bb1b5616b8fc")
|
|
self.assertTrue(is_m)
|
|
self.assertEqual(name, "tornado_cash")
|
|
|
|
is_m, name = _is_mixer("0x0000000000000000000000000000000000000000")
|
|
self.assertFalse(is_m)
|
|
|
|
def test_exchange_detection(self) -> None:
|
|
is_e, name = _is_exchange("0x3f5ce5fbfe3e9af3971dd833d26ba9b5c936f0be")
|
|
self.assertTrue(is_e)
|
|
self.assertEqual(name, "binance")
|
|
|
|
is_e, name = _is_exchange("0x71660c4005ba85c37ccec55d0c4493e66fe775d3")
|
|
self.assertTrue(is_e)
|
|
self.assertEqual(name, "coinbase")
|
|
|
|
is_e, name = _is_exchange("0x0000000000000000000000000000000000000000")
|
|
self.assertFalse(is_e)
|
|
|
|
def test_burn_address_detection(self) -> None:
|
|
self.assertTrue(_is_burn_address("0x0000000000000000000000000000000000000000"))
|
|
self.assertTrue(_is_burn_address("0x000000000000000000000000000000000000dead"))
|
|
self.assertFalse(_is_burn_address("0xabc123def456"))
|
|
|
|
|
|
class TestConfidenceScoring(unittest.TestCase):
|
|
"""Test confidence computation."""
|
|
|
|
def test_empty_hops(self) -> None:
|
|
self.assertEqual(_compute_confidence([]), 0.0)
|
|
|
|
def test_high_confidence_hops(self) -> None:
|
|
hops = [
|
|
TraceHop(
|
|
hop_number=1,
|
|
hop_type=TraceHopType.BRIDGE,
|
|
from_address="0xa",
|
|
to_address="0xb",
|
|
from_chain="ethereum",
|
|
to_chain="bsc",
|
|
protocol="stargate",
|
|
timestamp=int(datetime.now(tz=UTC).timestamp()),
|
|
tx_hash="0xabc123def",
|
|
),
|
|
]
|
|
score = _compute_confidence(hops)
|
|
self.assertGreater(score, 0.5)
|
|
|
|
def test_mixer_reduces_confidence(self) -> None:
|
|
hops = [
|
|
TraceHop(
|
|
hop_number=1,
|
|
hop_type=TraceHopType.MIXER,
|
|
from_address="0xa",
|
|
to_address="0xb",
|
|
from_chain="ethereum",
|
|
to_chain="ethereum",
|
|
protocol="tornado_cash",
|
|
),
|
|
]
|
|
mixer_score = _compute_confidence(hops)
|
|
|
|
clean_hops = [
|
|
TraceHop(
|
|
hop_number=1,
|
|
hop_type=TraceHopType.TRANSFER,
|
|
from_address="0xa",
|
|
to_address="0xb",
|
|
from_chain="ethereum",
|
|
to_chain="ethereum",
|
|
),
|
|
]
|
|
clean_score = _compute_confidence(clean_hops)
|
|
|
|
self.assertLess(mixer_score, clean_score + 0.1)
|
|
|
|
def test_tx_hash_boost(self) -> None:
|
|
hops_with_tx = [
|
|
TraceHop(
|
|
hop_number=1,
|
|
hop_type=TraceHopType.TRANSFER,
|
|
from_address="0xa",
|
|
to_address="0xb",
|
|
from_chain="ethereum",
|
|
to_chain="ethereum",
|
|
tx_hash="0xabc",
|
|
),
|
|
]
|
|
hops_without_tx = [
|
|
TraceHop(
|
|
hop_number=1,
|
|
hop_type=TraceHopType.TRANSFER,
|
|
from_address="0xa",
|
|
to_address="0xb",
|
|
from_chain="ethereum",
|
|
to_chain="ethereum",
|
|
),
|
|
]
|
|
self.assertGreater(
|
|
_compute_confidence(hops_with_tx),
|
|
_compute_confidence(hops_without_tx),
|
|
)
|
|
|
|
|
|
class TestSuspiciousnessScoring(unittest.TestCase):
|
|
"""Test suspiciousness computation."""
|
|
|
|
def test_clean_path_is_not_suspicious(self) -> None:
|
|
hops = [
|
|
TraceHop(
|
|
hop_number=1,
|
|
hop_type=TraceHopType.TRANSFER,
|
|
from_address="0xa",
|
|
to_address="0xb",
|
|
from_chain="ethereum",
|
|
to_chain="ethereum",
|
|
),
|
|
]
|
|
score = _compute_suspiciousness(hops)
|
|
self.assertLess(score, 0.3)
|
|
|
|
def test_mixer_path_is_suspicious(self) -> None:
|
|
hops = [
|
|
TraceHop(
|
|
hop_number=1,
|
|
hop_type=TraceHopType.MIXER,
|
|
from_address="0xa",
|
|
to_address="0xb",
|
|
from_chain="ethereum",
|
|
to_chain="ethereum",
|
|
tags=["mixer"],
|
|
),
|
|
TraceHop(
|
|
hop_number=2,
|
|
hop_type=TraceHopType.TRANSFER,
|
|
from_address="0xb",
|
|
to_address="0xc",
|
|
from_chain="ethereum",
|
|
to_chain="base",
|
|
),
|
|
]
|
|
score = _compute_suspiciousness(hops)
|
|
self.assertGreater(score, 0.1)
|
|
|
|
def test_multi_bridge_path(self) -> None:
|
|
hops = [
|
|
TraceHop(
|
|
hop_number=1,
|
|
hop_type=TraceHopType.BRIDGE,
|
|
from_address="0xa",
|
|
to_address="0xb",
|
|
from_chain="ethereum",
|
|
to_chain="bsc",
|
|
tags=["bridge"],
|
|
),
|
|
TraceHop(
|
|
hop_number=2,
|
|
hop_type=TraceHopType.BRIDGE,
|
|
from_address="0xb",
|
|
to_address="0xc",
|
|
from_chain="bsc",
|
|
to_chain="polygon",
|
|
tags=["bridge"],
|
|
),
|
|
TraceHop(
|
|
hop_number=3,
|
|
hop_type=TraceHopType.BRIDGE,
|
|
from_address="0xc",
|
|
to_address="0xd",
|
|
from_chain="polygon",
|
|
to_chain="arbitrum",
|
|
tags=["bridge"],
|
|
),
|
|
]
|
|
score = _compute_suspiciousness(hops)
|
|
self.assertGreater(score, 0.1)
|
|
|
|
def test_rapid_hops(self) -> None:
|
|
now = int(datetime.now(tz=UTC).timestamp())
|
|
hops = [
|
|
TraceHop(
|
|
hop_number=1,
|
|
hop_type=TraceHopType.TRANSFER,
|
|
from_address="0xa",
|
|
to_address="0xb",
|
|
from_chain="ethereum",
|
|
to_chain="ethereum",
|
|
timestamp=now - 300,
|
|
),
|
|
TraceHop(
|
|
hop_number=2,
|
|
hop_type=TraceHopType.TRANSFER,
|
|
from_address="0xb",
|
|
to_address="0xc",
|
|
from_chain="ethereum",
|
|
to_chain="ethereum",
|
|
timestamp=now - 200,
|
|
),
|
|
TraceHop(
|
|
hop_number=3,
|
|
hop_type=TraceHopType.TRANSFER,
|
|
from_address="0xc",
|
|
to_address="0xd",
|
|
from_chain="ethereum",
|
|
to_chain="ethereum",
|
|
timestamp=now - 100,
|
|
),
|
|
]
|
|
score = _compute_suspiciousness(hops)
|
|
self.assertGreater(score, 0.1)
|
|
|
|
def test_empty_hops_no_suspicion(self) -> None:
|
|
self.assertEqual(_compute_suspiciousness([]), 0.0)
|
|
|
|
|
|
class TestSummary(unittest.TestCase):
|
|
"""Test summary generation."""
|
|
|
|
def test_basic_summary(self) -> None:
|
|
result = FundTraceResult(source_address="0xtest")
|
|
result.chains_used = ["ethereum"]
|
|
result.chain_count = 1
|
|
result.suspicious_score = 0.1
|
|
result.confidence_score = 0.8
|
|
summary = _generate_summary(result)
|
|
self.assertIn("10%", summary)
|
|
self.assertIn("80%", summary)
|
|
|
|
def test_mixer_summary(self) -> None:
|
|
result = FundTraceResult(source_address="0xevil")
|
|
result.mixer_hops = 2
|
|
result.bridge_hops = 1
|
|
result.chain_count = 3
|
|
result.chains_used = ["ethereum", "bsc", "arbitrum"]
|
|
result.suspicious_score = 0.7
|
|
result.confidence_score = 0.5
|
|
result.source_of_funds = "mixer:tornado_cash"
|
|
summary = _generate_summary(result)
|
|
self.assertIn("mixer", summary.lower())
|
|
self.assertIn("HIGH SUSPICION", summary)
|
|
|
|
def test_safe_summary(self) -> None:
|
|
result = FundTraceResult(source_address="0xsafe")
|
|
result.suspicious_score = 0.05
|
|
result.confidence_score = 0.9
|
|
summary = _generate_summary(result)
|
|
self.assertNotIn("HIGH SUSPICION", summary.upper())
|
|
self.assertNotIn("MODERATE SUSPICION", summary.upper())
|
|
|
|
|
|
class TestToDict(unittest.TestCase):
|
|
"""Test serialization."""
|
|
|
|
def test_fund_trace_result_to_dict(self) -> None:
|
|
result = FundTraceResult(source_address="0xabc")
|
|
result.trace_path = [
|
|
TraceHop(
|
|
hop_number=1,
|
|
hop_type=TraceHopType.BRIDGE,
|
|
from_address="0xa",
|
|
to_address="0xb",
|
|
from_chain="ethereum",
|
|
to_chain="bsc",
|
|
protocol="stargate",
|
|
amount_usd=10000.0,
|
|
confidence=ConfidenceLevel.HIGH,
|
|
),
|
|
]
|
|
d = result.to_dict()
|
|
self.assertEqual(d["source_address"], "0xabc")
|
|
self.assertEqual(len(d["trace_path"]), 1)
|
|
self.assertEqual(d["trace_path"][0]["protocol"], "stargate")
|
|
self.assertEqual(d["trace_path"][0]["confidence"], "high")
|
|
|
|
def test_empty_result_serialization(self) -> None:
|
|
result = FundTraceResult(source_address="0xempty")
|
|
d = result.to_dict()
|
|
self.assertEqual(d["source_address"], "0xempty")
|
|
self.assertEqual(len(d["trace_path"]), 0)
|
|
self.assertIn("summary", d)
|
|
self.assertIn("analysis_time_ms", d)
|
|
|
|
def test_trace_hop_serialization(self) -> None:
|
|
hop = TraceHop(
|
|
hop_number=1,
|
|
hop_type=TraceHopType.SWAP,
|
|
from_address="0xa",
|
|
to_address="0xb",
|
|
from_chain="ethereum",
|
|
to_chain="ethereum",
|
|
token="USDC",
|
|
amount_usd=50000.0,
|
|
protocol="uniswap",
|
|
is_suspicious=False,
|
|
tags=["swap", "stablecoin"],
|
|
)
|
|
d = hop.to_dict()
|
|
self.assertEqual(d["type"], "swap")
|
|
self.assertEqual(d["token"], "USDC")
|
|
self.assertEqual(d["amount_usd"], 50000.0)
|
|
self.assertEqual(d["protocol"], "uniswap")
|
|
self.assertIn("swap", d["tags"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|