rmi-backend/tests/unit/test_cross_chain_trace.py

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