rmi-backend/tests/unit/test_oracle_manipulation_detector.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- 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>
2026-07-06 15:43:20 +02:00

594 lines
20 KiB
Python

"""
Tests for Oracle Manipulation Detector
=======================================
Covers core detection heuristics: TWAP manipulation, Chainlink staleness,
flash loan-backed manipulation, cross-pool divergence, severity
classification, sandwich detection, and data model serialization -
all without requiring network calls.
"""
import json
import unittest
from app.oracle_manipulation_detector import (
CHAINLINK_FEEDS,
CROSS_POOL_DIVERGENCE_THRESHOLD,
KNOWN_LP_POOLS,
MIN_TWAP_SAMPLES,
TWAP_MANIPULATION_THRESHOLD_PCT,
ManipulationType,
OracleManipulationDetector,
OracleRead,
OracleType,
PriceManipulation,
PriceSnapshot,
Severity,
_calculate_twap_from_samples,
_classify_severity,
_compute_deviation_pct,
_detect_twap_manipulation,
_is_sandwich_pattern,
)
class TestPriceSnapshot(unittest.TestCase):
"""PriceSnapshot data model tests."""
def test_creation(self):
snap = PriceSnapshot(
timestamp=1234567890.0,
block_number=20000000,
price=3200.50,
liquidity=10000000,
source="Uniswap V3",
chain="ethereum",
)
self.assertEqual(snap.price, 3200.50)
self.assertEqual(snap.block_number, 20000000)
self.assertEqual(snap.chain, "ethereum")
def test_to_dict(self):
snap = PriceSnapshot(
timestamp=1234567890.0,
block_number=20000000,
price=3200.50,
)
d = snap.to_dict()
self.assertEqual(d["price"], 3200.50)
self.assertEqual(d["block_number"], 20000000)
self.assertEqual(d["chain"], "ethereum") # default
class TestOracleRead(unittest.TestCase):
"""OracleRead data model and staleness detection tests."""
def test_fresh_feed(self):
read = OracleRead(
tx_hash="0xabc",
block_number=20000000,
timestamp=1234567890.0,
oracle_address="0xdead",
oracle_type=OracleType.CHAINLINK,
reported_price=3200.0,
expected_price=3200.0,
price_age_seconds=300, # 5 min - fresh
)
self.assertFalse(read.is_stale())
def test_stale_feed(self):
read = OracleRead(
tx_hash="0xabc",
block_number=20000000,
timestamp=1234567890.0,
oracle_address="0xdead",
oracle_type=OracleType.CHAINLINK,
reported_price=3200.0,
expected_price=3200.0,
price_age_seconds=86400, # 24 hours - very stale
)
self.assertTrue(read.is_stale())
def test_deviation_calculation(self):
read = OracleRead(
tx_hash="0xabc",
block_number=20000000,
timestamp=1234567890.0,
oracle_address="0xdead",
oracle_type=OracleType.CHAINLINK,
reported_price=3500.0,
expected_price=3200.0,
)
deviation = read.deviation_from_expected()
self.assertIsNotNone(deviation)
self.assertAlmostEqual(deviation, 0.09375, places=5) # 9.375%
def test_deviation_none_when_no_expected(self):
read = OracleRead(
tx_hash="0xabc",
block_number=20000000,
timestamp=1234567890.0,
oracle_address="0xdead",
oracle_type=OracleType.CHAINLINK,
reported_price=3200.0,
)
self.assertIsNone(read.deviation_from_expected())
def test_to_dict(self):
read = OracleRead(
tx_hash="0xabc",
block_number=20000000,
timestamp=1234567890.0,
oracle_address="0xdead",
oracle_type=OracleType.CHAINLINK,
reported_price=3200.0,
expected_price=3200.0,
price_age_seconds=300,
chain="ethereum",
protocol="Chainlink",
)
d = read.to_dict()
self.assertEqual(d["oracle_type"], "chainlink")
self.assertFalse(d["is_stale"])
self.assertEqual(d["deviation_pct"], 0.0)
def test_to_dict_no_expected(self):
read = OracleRead(
tx_hash="0xabc",
block_number=20000000,
timestamp=1234567890.0,
oracle_address="0xdead",
oracle_type=OracleType.CHAINLINK,
reported_price=3200.0,
)
d = read.to_dict()
self.assertIsNone(d["expected_price"])
self.assertIsNone(d["deviation_pct"])
class TestOracleType(unittest.TestCase):
"""OracleType enum tests."""
def test_from_string(self):
self.assertEqual(OracleType.from_string("chainlink"), OracleType.CHAINLINK)
self.assertEqual(OracleType.from_string("uniswap_v3_twap"), OracleType.UNISWAP_V3_TWAP)
self.assertEqual(OracleType.from_string("uniswap_v2_twap"), OracleType.UNISWAP_V2_TWAP)
self.assertEqual(OracleType.from_string("curve_ema"), OracleType.CURVE_EMA)
self.assertEqual(OracleType.from_string("unknown_foo"), OracleType.CUSTOM)
self.assertEqual(OracleType.from_string(""), OracleType.CUSTOM)
class TestSeverity(unittest.TestCase):
"""Severity enum ordering and score tests."""
def test_severity_ordering(self):
self.assertLess(Severity.INFO, Severity.LOW)
self.assertLess(Severity.LOW, Severity.MEDIUM)
self.assertLess(Severity.MEDIUM, Severity.HIGH)
self.assertLess(Severity.HIGH, Severity.CRITICAL)
def test_severity_scores(self):
self.assertEqual(Severity.CRITICAL.score, 1.0)
self.assertEqual(Severity.INFO.score, 0.0)
self.assertGreater(Severity.HIGH.score, Severity.MEDIUM.score)
class TestManipulationType(unittest.TestCase):
"""ManipulationType label tests."""
def test_label_format(self):
mt = ManipulationType.TWAP_POISONING
self.assertEqual(mt.value, "twap_poisoning")
class TestPriceManipulation(unittest.TestCase):
"""PriceManipulation data model tests."""
def setUp(self):
self.incident = PriceManipulation(
manipulation_type=ManipulationType.TWAP_POISONING,
severity=Severity.HIGH,
chain="ethereum",
block_number=20000000,
tx_hash="0xdeadbeef",
timestamp=1234567890.0,
pool_address="0xpool",
pair="WETH/USDC",
oracle_type=OracleType.UNISWAP_V3_TWAP,
observed_price=3400.0,
expected_price=3200.0,
deviation_pct=6.25,
description="TWAP manipulation detected",
evidence=["std_dev=0.03", "risk=0.75"],
)
def test_summary_contains_info(self):
s = self.incident.summary()
self.assertIn("HIGH", s)
self.assertIn("TWAP", s)
self.assertIn("WETH/USDC", s)
self.assertIn("6.25", s)
def test_label(self):
self.assertEqual(self.incident.label, "Twap Poisoning")
def test_to_dict(self):
d = self.incident.to_dict()
self.assertEqual(d["type"], "twap_poisoning")
self.assertEqual(d["severity"], "high")
self.assertEqual(d["deviation_pct"], 6.25)
self.assertFalse(d["external_fatigue"])
class TestTWAPWindow(unittest.TestCase):
"""TWAPWindow computation and risk scoring tests."""
def setUp(self):
# Create stable price samples
self.stable_samples = [
PriceSnapshot(
timestamp=1234567800.0 + i * 12,
block_number=20000000 + i,
price=3200.0,
liquidity=10000000,
)
for i in range(10)
]
# Create volatile price samples (manipulation pattern)
self.volatile_samples = []
for i in range(10):
if i in (3, 4, 5): # Spike in middle # noqa: SIM108
price = 3700.0
else:
price = 3200.0
self.volatile_samples.append(
PriceSnapshot(
timestamp=1234567800.0 + i * 12,
block_number=20000000 + i,
price=price,
liquidity=10000000,
)
)
def test_stable_twap(self):
window = _calculate_twap_from_samples(self.stable_samples)
self.assertIsNotNone(window)
self.assertEqual(window.average_price, 3200.0)
self.assertEqual(window.min_price, 3200.0)
self.assertEqual(window.max_price, 3200.0)
self.assertEqual(window.std_dev_pct, 0.0)
# 0 std dev + 10 samples = 0 manipulation risk
self.assertEqual(window.manipulation_risk(), 0.0)
def test_volatile_twap(self):
window = _calculate_twap_from_samples(self.volatile_samples)
self.assertIsNotNone(window)
self.assertGreater(window.average_price, 3200.0)
self.assertGreater(window.std_dev_pct, 0.01)
self.assertGreater(window.manipulation_risk(), 0.2)
def test_twap_manipulation_detection(self):
# Compute TWAP from volatile samples
window = _calculate_twap_from_samples(self.volatile_samples)
self.assertIsNotNone(window)
incident = _detect_twap_manipulation(window)
# High volatility should trigger detection
self.assertIsNotNone(incident)
self.assertEqual(incident.manipulation_type, ManipulationType.TWAP_POISONING)
self.assertIn(incident.severity, [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW])
def test_insufficient_samples(self):
samples = [PriceSnapshot(timestamp=1.0, block_number=1, price=100.0)]
self.assertIsNone(_calculate_twap_from_samples(samples))
def test_twap_to_dict(self):
window = _calculate_twap_from_samples(self.stable_samples)
d = window.to_dict()
self.assertIn("average_price", d)
self.assertIn("manipulation_risk", d)
self.assertIn("samples", d)
self.assertEqual(d["samples"], 10)
class TestHeuristicHelpers(unittest.TestCase):
"""Test standalone heuristic functions."""
def test_compute_deviation_pct(self):
self.assertAlmostEqual(_compute_deviation_pct(3200.0, 3200.0), 0.0)
self.assertAlmostEqual(_compute_deviation_pct(3400.0, 3200.0), 0.0625, places=4)
self.assertAlmostEqual(_compute_deviation_pct(3000.0, 3200.0), 0.0625, places=4)
def test_compute_deviation_zero(self):
self.assertEqual(_compute_deviation_pct(100.0, 0.0), 0.0)
def test_classify_severity_normal(self):
self.assertEqual(_classify_severity(0.01), Severity.INFO)
self.assertEqual(_classify_severity(0.04), Severity.LOW)
self.assertEqual(_classify_severity(0.09), Severity.MEDIUM)
self.assertEqual(_classify_severity(0.20), Severity.HIGH)
self.assertEqual(_classify_severity(0.50), Severity.CRITICAL)
def test_classify_severity_flash_loan(self):
# Flash loan thresholds are tighter
self.assertEqual(_classify_severity(0.04, is_flash_loan=True), Severity.INFO)
self.assertEqual(_classify_severity(0.06, is_flash_loan=True), Severity.MEDIUM)
self.assertEqual(_classify_severity(0.12, is_flash_loan=True), Severity.HIGH)
self.assertEqual(_classify_severity(0.25, is_flash_loan=True), Severity.CRITICAL)
def test_sandwich_pattern_detection(self):
# Classic sandwich: low → high → low
prices = [
100.0,
100.0,
100.0,
100.0,
100.0,
110.0,
110.0, # spike
100.0,
100.0,
100.0,
100.0,
100.0,
]
self.assertTrue(_is_sandwich_pattern(prices, window=3))
def test_no_sandwich_pattern(self):
# Flat prices
prices = [100.0] * 15
self.assertFalse(_is_sandwich_pattern(prices))
def test_sandwich_too_few_samples(self):
self.assertFalse(_is_sandwich_pattern([100.0, 100.0], window=5))
class TestOracleManipulationDetector(unittest.TestCase):
"""Integration tests for OracleManipulationDetector."""
def setUp(self):
self.detector = OracleManipulationDetector(chain="ethereum")
def test_scan_produces_report(self):
"""Full scan should produce a valid report structure."""
import asyncio
report = asyncio.run(self.detector.scan(blocks_back=30))
self.assertIsNotNone(report)
self.assertGreater(report.blocks_scanned, 0)
self.assertGreater(len(report.scan_id), 0)
def test_scan_checks_pools(self):
"""Scan should check some LP pools."""
import asyncio
report = asyncio.run(self.detector.scan(blocks_back=30))
self.assertGreater(len(report.twap_windows), 0)
self.assertEqual(len(report.incidents) + len(report.errors), report.total_incidents + len(report.errors))
def test_pool_analysis(self):
"""Pool analysis should produce a TWAP window."""
import asyncio
report = asyncio.run(
self.detector.analyze_pool(
"0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640",
"WETH/USDC",
)
)
self.assertIsNotNone(report)
self.assertEqual(report.pools_checked, 1)
if report.twap_windows:
twap = report.twap_windows[0]
self.assertGreater(twap.average_price, 0)
def test_chainlink_feed_check(self):
"""Chainlink feed check should return feeds."""
import asyncio
reads = asyncio.run(self.detector.check_chainlink_feeds(["ETH/USD", "BTC/USD"]))
self.assertGreater(len(reads), 0)
for read in reads:
self.assertEqual(read.oracle_type, OracleType.CHAINLINK)
self.assertGreater(read.reported_price, 0)
def test_multichain_scan(self):
"""Multi-chain scan should work."""
import asyncio
report = asyncio.run(
self.detector.scan(
blocks_back=10,
chains=["ethereum", "base"],
)
)
self.assertIsNotNone(report)
self.assertIn("ethereum", report.chain)
self.assertIn("base", report.chain)
def test_report_json_serialization(self):
"""Report should serialize to valid JSON."""
import asyncio
report = asyncio.run(self.detector.scan(blocks_back=10))
json_str = report.json()
data = json.loads(json_str)
self.assertIn("scan_id", data)
self.assertIn("incidents", data)
self.assertIn("twap_windows", data)
class TestPriceManipulationReport(unittest.TestCase):
"""ManipulationReport aggregation tests."""
def test_risk_score_calculation(self):
from app.oracle_manipulation_detector import ManipulationReport
report = ManipulationReport(
scan_id="test",
chain="ethereum",
blocks_scanned=50,
start_time=0.0,
end_time=1.0,
)
self.assertEqual(report.risk_score, 0.0)
report.incidents.append(
PriceManipulation(
manipulation_type=ManipulationType.TWAP_POISONING,
severity=Severity.CRITICAL,
chain="ethereum",
block_number=1,
)
)
self.assertEqual(report.risk_score, 1.0)
def test_incident_counts(self):
from app.oracle_manipulation_detector import ManipulationReport
report = ManipulationReport(
scan_id="test",
chain="ethereum",
blocks_scanned=50,
start_time=0.0,
end_time=1.0,
)
report.incidents.append(
PriceManipulation(
manipulation_type=ManipulationType.TWAP_POISONING,
severity=Severity.CRITICAL,
chain="ethereum",
block_number=1,
)
)
report.incidents.append(
PriceManipulation(
manipulation_type=ManipulationType.CHAINLINK_STALE,
severity=Severity.HIGH,
chain="ethereum",
block_number=2,
)
)
report.incidents.append(
PriceManipulation(
manipulation_type=ManipulationType.LP_PRICE_DIVERGENCE,
severity=Severity.LOW,
chain="ethereum",
block_number=3,
)
)
self.assertEqual(report.critical_count, 1)
self.assertEqual(report.high_count, 1)
self.assertEqual(report.total_incidents, 3)
def test_duration(self):
from app.oracle_manipulation_detector import ManipulationReport
report = ManipulationReport(
scan_id="test",
chain="ethereum",
blocks_scanned=50,
start_time=100.0,
end_time=150.0,
)
self.assertEqual(report.duration_seconds, 50.0)
class TestKnownConstants(unittest.TestCase):
"""Test that KNOWN_LP_POOLS and CHAINLINK_FEEDS are well-formed."""
def test_known_lp_pools_have_expected_structure(self):
for _pair, pools in KNOWN_LP_POOLS.items():
for pool in pools:
self.assertIn("protocol", pool)
self.assertIn("address", pool)
self.assertIn(
pool["protocol"],
[
"Uniswap V3",
"Uniswap V2",
"Curve",
"Balancer",
],
)
def test_chainlink_feeds_have_required_fields(self):
for _name, feed in CHAINLINK_FEEDS.items():
self.assertIn("address", feed)
self.assertIn("decimals", feed)
self.assertIn("heartbeat", feed)
self.assertGreater(feed["decimals"], 0)
def test_MIN_TWAP_SAMPLES_reasonable(self):
self.assertGreaterEqual(MIN_TWAP_SAMPLES, 2)
self.assertLessEqual(MIN_TWAP_SAMPLES, 10)
def test_cross_pool_divergence_threshold_positive(self):
self.assertGreater(CROSS_POOL_DIVERGENCE_THRESHOLD, 0)
self.assertLess(CROSS_POOL_DIVERGENCE_THRESHOLD, 0.5)
def test_twap_manipulation_threshold_positive(self):
self.assertGreater(TWAP_MANIPULATION_THRESHOLD_PCT, 0)
self.assertLess(TWAP_MANIPULATION_THRESHOLD_PCT, 0.5)
class TestEdgeCases(unittest.TestCase):
"""Edge case and boundary tests."""
def test_empty_twap_samples_returns_none(self):
self.assertIsNone(_calculate_twap_from_samples([]))
def test_single_sample_returns_none(self):
samples = [PriceSnapshot(timestamp=1.0, block_number=1, price=100.0)]
self.assertIsNone(_calculate_twap_from_samples(samples))
def test_two_samples_returns_none(self):
samples = [
PriceSnapshot(timestamp=1.0, block_number=1, price=100.0),
PriceSnapshot(timestamp=2.0, block_number=2, price=101.0),
]
self.assertIsNone(_calculate_twap_from_samples(samples))
def test_all_samples_same_price(self):
samples = [PriceSnapshot(timestamp=float(i), block_number=i, price=100.0) for i in range(10)]
window = _calculate_twap_from_samples(samples)
self.assertIsNotNone(window)
self.assertEqual(window.std_dev_pct, 0.0)
def test_extreme_price_spike(self):
samples = [PriceSnapshot(timestamp=float(i), block_number=i, price=100.0) for i in range(9)]
samples.append(PriceSnapshot(timestamp=10.0, block_number=10, price=10000.0)) # 100x spike
window = _calculate_twap_from_samples(samples)
self.assertIsNotNone(window)
self.assertGreater(window.manipulation_risk(), 0.5)
def test_price_near_zero(self):
read = OracleRead(
tx_hash="0xabc",
block_number=1,
timestamp=1.0,
oracle_address="0xfeed",
oracle_type=OracleType.CHAINLINK,
reported_price=0.001,
expected_price=0.001,
)
dev = read.deviation_from_expected()
self.assertIsNotNone(dev)
self.assertAlmostEqual(dev, 0.0)
def test_classify_severity_extreme(self):
self.assertEqual(_classify_severity(1.0), Severity.CRITICAL)
self.assertEqual(_classify_severity(0.5), Severity.CRITICAL)
def test_sandwich_pattern_small_window(self):
prices = [100.0, 200.0, 100.0]
self.assertTrue(_is_sandwich_pattern(prices, window=1))
def test_no_sandwich_missing_recovery(self):
prices = [100.0, 100.0, 100.0, 200.0, 200.0, 200.0]
self.assertFalse(_is_sandwich_pattern(prices, window=2))
if __name__ == "__main__":
unittest.main()