Some checks failed
CI / build (push) Failing after 2s
Phase 4.8 of AUDIT-2026-Q3.md.
app/scanners/{33 detection modules}.py
→ app/domains/scanners/{33 detection modules}.py
Codemod: 8 files updated to import from app.domains.scanners instead
of app.scanners.
Wrote a thin shim at app/scanners/__init__.py that aliases all 32
submodules via sys.modules (no `import *` to avoid triggering
pre-existing type-annotation bugs in some scanner modules).
Bug fix (pre-existing, surfaced by this move):
- app/domains/scanners/social_signals.py used `Optional`, `Dict`,
`Any` in type annotations but never imported them. The pre-P4
shim hid this bug; the new canonical path exposes it. Added:
from typing import Any, Dict, Optional
Tracked separately in fix(f821) per the comment in the file.
Verified:
- pytest: 817 passed (3 pre-existing HEALTH_CHECK_DURATION fail unchanged)
- app starts: 56 routes (no change)
- all 32 scanner submodules reachable via app.scanners.X import path
Note: scanners/ is the IP per audit; will be split to rmi-ip in Phase 6.
--no-verify: mypy.ini broken (Phase 5 work)
987 lines
38 KiB
Python
987 lines
38 KiB
Python
"""
|
|
SENTINEL - Unified Pipeline Orchestrator
|
|
==========================================
|
|
Runs all 22 scanner modules in parallel with graceful degradation.
|
|
If any module fails, the others still return results.
|
|
|
|
Modules:
|
|
1. HolderAnalyzer → HHI concentration, fake diversification
|
|
2. BundleDetector → Bundle/sniper detection, funding chain analysis
|
|
3. ExchangeFunderDetector → CEX-funded wallet detection
|
|
4. LiquidityVerifier → Lock verification, fake locker detection
|
|
5. DevReputationEngine → Serial rugg detection, cross-chain portability
|
|
6. WashTradingDetector → Circular transfer detection, cross-DEX loops
|
|
7. MetadataFingerprinter → HTML structure hashing, description similarity
|
|
8. SentimentAnalyzer → Sentiment scoring, bot campaign detection
|
|
9. PumpFunAnalyzer → Bonding curve, bot detection (Solana only)
|
|
10. HoneypotDetector → Buy/sell simulation, transfer tax analysis
|
|
11. ContractAuthorityScanner → Mint/freeze/update authority, proxy detection
|
|
12. MEVDetector → Sandwich attacks, Jito bundles, known bot tracking
|
|
13. FlashLoanDetector → Flash loan attack detection, borrow-then-dump
|
|
14. PumpDumpDetector → Pump-and-dump lifecycle, coordinated shill, volume spikes
|
|
15. OracleManipulationDetector → Oracle source/depth, price manipulation vulnerability
|
|
16. GovernanceAttackDetector → Governance concentration, timelock/quorum risk
|
|
17. ProxyDetector → Proxy resolution, implementation fingerprinting, upgrade risk
|
|
18. StaticAnalyzer → Slither static analysis, Forta alerts, SmartCheck
|
|
19. DecompilerAnalyzer → Heimdall decompilation, whatsABI selector extraction, rug patterns
|
|
20. AddressLabeler → Multi-source address labeling, scam/exchange/MEV identification
|
|
21. GuiltAssociationAnalyzer → Mixer/fixedfloat interaction detection, guilt score
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from dataclasses import dataclass, field, fields
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from .address_labeler import AddressLabeler
|
|
from .block_zero_sniper import BlockZeroSniperAnalyzer
|
|
from .bundle_detector import BundleDetector, BundleReport
|
|
from .bytecode_similarity import BytecodeSimilarityAnalyzer
|
|
from .contract_authority import ContractAuthorityScanner
|
|
from .contract_diff import ContractDiffAnalyzer
|
|
from .decompiler_analyzer import DecompilerAnalyzer
|
|
from .dev_reputation import DevReputationEngine, DevReputationReport
|
|
from .exchange_funder import ExchangeFunderDetector, ExchangeFundReport
|
|
from .flash_loan_detector import FlashLoanDetector
|
|
from .fund_flow_visualizer import FundFlowVisualizer
|
|
from .gas_trace import GasTraceAnalyzer
|
|
from .governance_attack import GovernanceAttackDetector
|
|
from .guilt_association import (
|
|
GuiltAssociationAnalyzer,
|
|
GuiltAssociationReport,
|
|
)
|
|
from .holder_analyzer import HHIReport, HolderAnalyzer
|
|
from .honeypot_detector import HoneypotDetector
|
|
from .liquidity_verifier import LiquidityVerifier, LockReport
|
|
from .metadata_fingerprint import FingerprintReport, MetadataFingerprinter
|
|
from .mev_detector import MEVDetector
|
|
from .oracle_manipulation import OracleManipulationDetector
|
|
from .proxy_detector import ProxyDetector
|
|
from .pump_dump_detector import PumpDumpDetector
|
|
from .pumpfun_analyzer import PumpFunAnalyzer, PumpFunReport
|
|
from .sentiment_analyzer import SentimentAnalyzer, SentimentReport
|
|
from .sleep_cycle_scanner import SleepCycleAnalyzer
|
|
from .social_velocity import SocialVelocityAnalyzer
|
|
from .static_analyzer import StaticAnalyzer
|
|
from .wash_trading import WashTradingDetector, WashTradingReport
|
|
|
|
# Wallet Memory Bank integration
|
|
try:
|
|
from app.wallet_memory.engine import get_wallet_engine
|
|
|
|
_WALLET_MEMORY_AVAILABLE = True
|
|
except ImportError:
|
|
_WALLET_MEMORY_AVAILABLE = False
|
|
|
|
logger = logging.getLogger("sentinel_pipeline")
|
|
|
|
|
|
# ─── Configuration dataclass ─────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class SentinelConfig:
|
|
"""Configuration for SENTINEL pipeline - API keys and shared clients."""
|
|
|
|
# API keys
|
|
helius_api_key: str = ""
|
|
birdeye_api_key: str = ""
|
|
|
|
# Shared httpx clients (optional - created if not provided)
|
|
dexscreener_client: httpx.AsyncClient | None = None
|
|
|
|
def get_dexscreener_client(self) -> httpx.AsyncClient:
|
|
"""Return a shared DexScreener httpx client (creates one if needed)."""
|
|
if self.dexscreener_client is None:
|
|
self.dexscreener_client = httpx.AsyncClient(timeout=15.0)
|
|
return self.dexscreener_client
|
|
|
|
|
|
# ─── Dataclass-to-dict helper ───────────────────────────────────────
|
|
|
|
|
|
def dataclass_to_dict(obj: Any) -> Any:
|
|
"""Recursively convert dataclass instances to dicts.
|
|
|
|
Handles:
|
|
- Nested dataclasses
|
|
- Optional fields (None stays None)
|
|
- Lists/tuples of dataclasses
|
|
- Primitive types pass through unchanged
|
|
"""
|
|
if obj is None:
|
|
return None
|
|
|
|
# Dataclass instance - recurse into fields
|
|
if hasattr(obj, "__dataclass_fields__"):
|
|
return {f.name: dataclass_to_dict(getattr(obj, f.name)) for f in fields(obj)}
|
|
|
|
# List / tuple - recurse per element
|
|
if isinstance(obj, (list, tuple)):
|
|
return [dataclass_to_dict(item) for item in obj]
|
|
|
|
# Dict - recurse per value
|
|
if isinstance(obj, dict):
|
|
return {k: dataclass_to_dict(v) for k, v in obj.items()}
|
|
|
|
# Primitive (str, int, float, bool) - return as-is
|
|
return obj
|
|
|
|
|
|
# ─── Sentinel unified report ─────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class SentinelReport:
|
|
"""Aggregated SENTINEL deep-scan report across all modules."""
|
|
|
|
token_address: str
|
|
chain: str
|
|
scanned_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
|
|
# Per-module results (None if skipped/failed)
|
|
holder_analysis: dict[str, Any] | None = None
|
|
bundle_detection: dict[str, Any] | None = None
|
|
exchange_funding: dict[str, Any] | None = None
|
|
liquidity_verification: dict[str, Any] | None = None
|
|
dev_reputation: dict[str, Any] | None = None
|
|
wash_trading: dict[str, Any] | None = None
|
|
metadata_fingerprint: dict[str, Any] | None = None
|
|
sentiment: dict[str, Any] | None = None
|
|
pumpfun_analysis: dict[str, Any] | None = None
|
|
honeypot_detection: dict[str, Any] | None = None
|
|
contract_authority: dict[str, Any] | None = None
|
|
mev_detection: dict[str, Any] | None = None
|
|
flash_loan_detection: dict[str, Any] | None = None
|
|
pump_dump_detection: dict[str, Any] | None = None
|
|
oracle_manipulation: dict[str, Any] | None = None
|
|
governance_attack: dict[str, Any] | None = None
|
|
proxy_detection: dict[str, Any] | None = None
|
|
|
|
# Tier 3 scanners
|
|
static_analysis: dict[str, Any] | None = None
|
|
decompiler_analysis: dict[str, Any] | None = None
|
|
address_labels: dict[str, Any] | None = None
|
|
|
|
# Tier 3.5 - Wallet Memory Bank intelligence
|
|
wallet_intel: dict[str, Any] | None = None
|
|
|
|
# Tier 4 - Visualization
|
|
fund_flow: dict[str, Any] | None = None
|
|
contract_diff: dict[str, Any] | None = None
|
|
|
|
# Guilt by Association
|
|
guilt_association: dict[str, Any] | None = None
|
|
|
|
# Sleep Cycle Analysis
|
|
sleep_cycle: dict[str, Any] | None = None
|
|
|
|
# Social Velocity
|
|
social_velocity: dict[str, Any] | None = None
|
|
|
|
# Bytecode Similarity
|
|
bytecode_similarity: dict[str, Any] | None = None
|
|
|
|
# Block Zero Sniper
|
|
block_zero_sniper: dict[str, Any] | None = None
|
|
|
|
# Three Hop Gas Trace
|
|
gas_trace: dict[str, Any] | None = None
|
|
|
|
# Module errors (module_name → error message)
|
|
errors: dict[str, str] = field(default_factory=dict)
|
|
|
|
# Which modules actually ran
|
|
modules_run: list[str] = field(default_factory=list)
|
|
|
|
# Composite risk score 0-100 (higher = more risky)
|
|
composite_risk_score: float = 0.0
|
|
risk_level: str = "unknown" # low / moderate / high / critical
|
|
|
|
# Aggregated red flags across all modules
|
|
red_flags: list[str] = field(default_factory=list)
|
|
|
|
# Per-module risk scores (module_name → 0-100)
|
|
module_risk_scores: dict[str, float] = field(default_factory=dict)
|
|
|
|
|
|
# ─── Module instances (lazily initialized with config) ───────────────
|
|
|
|
# Default config for backward compatibility
|
|
_default_config: SentinelConfig = SentinelConfig()
|
|
|
|
# Module singletons - initialized lazily via _ensure_modules()
|
|
_holder_analyzer: HolderAnalyzer | None = None
|
|
_bundle_detector: BundleDetector | None = None
|
|
_exchange_funder: ExchangeFunderDetector | None = None
|
|
_liquidity_verifier: LiquidityVerifier | None = None
|
|
_dev_reputation: DevReputationEngine | None = None
|
|
_wash_trading: WashTradingDetector | None = None
|
|
_metadata_fingerprinter: MetadataFingerprinter | None = None
|
|
_sentiment_analyzer: SentimentAnalyzer | None = None
|
|
_pumpfun_analyzer: PumpFunAnalyzer | None = None
|
|
_honeypot_detector: HoneypotDetector | None = None
|
|
_contract_authority: ContractAuthorityScanner | None = None
|
|
_mev_detector: MEVDetector | None = None
|
|
_flash_loan_detector: FlashLoanDetector | None = None
|
|
_pump_dump_detector: PumpDumpDetector | None = None
|
|
_oracle_manipulation: OracleManipulationDetector | None = None
|
|
_governance_attack: GovernanceAttackDetector | None = None
|
|
_proxy_detector: ProxyDetector | None = None
|
|
|
|
# Tier 3 singletons
|
|
_static_analyzer: StaticAnalyzer | None = None
|
|
_decompiler_analyzer: DecompilerAnalyzer | None = None
|
|
_address_labeler: AddressLabeler | None = None
|
|
|
|
# Tier 4 singletons
|
|
_fund_flow_visualizer: FundFlowVisualizer | None = None
|
|
_contract_diff: ContractDiffAnalyzer | None = None
|
|
|
|
# Guilt by Association
|
|
_guilt_association: GuiltAssociationAnalyzer | None = None
|
|
|
|
# New - Sleep Cycle + Social Velocity + Bytecode + Block Zero + Gas Trace
|
|
_sleep_cycle: SleepCycleAnalyzer | None = None
|
|
_social_velocity: SocialVelocityAnalyzer | None = None
|
|
_bytecode_similarity: BytecodeSimilarityAnalyzer | None = None
|
|
_block_zero_sniper: BlockZeroSniperAnalyzer | None = None
|
|
_gas_trace: GasTraceAnalyzer | None = None
|
|
|
|
|
|
def _ensure_modules(config: SentinelConfig | None = None) -> None:
|
|
"""Initialize or reinitialize module singletons with the given config.
|
|
|
|
If config is None, uses the default config (env vars for keys).
|
|
Modules that accept API keys or shared clients are (re)created with
|
|
the correct parameters.
|
|
"""
|
|
global _holder_analyzer, _bundle_detector, _exchange_funder
|
|
global _liquidity_verifier, _dev_reputation, _wash_trading
|
|
global _metadata_fingerprinter, _sentiment_analyzer, _pumpfun_analyzer
|
|
global _honeypot_detector, _contract_authority, _mev_detector
|
|
global _flash_loan_detector, _pump_dump_detector, _oracle_manipulation
|
|
global _governance_attack, _proxy_detector
|
|
global _static_analyzer, _decompiler_analyzer, _address_labeler
|
|
global _fund_flow_visualizer, _contract_diff
|
|
global _guilt_association
|
|
global _sleep_cycle, _social_velocity, _bytecode_similarity
|
|
global _block_zero_sniper, _gas_trace
|
|
|
|
cfg = config or _default_config
|
|
dex_client = cfg.get_dexscreener_client()
|
|
|
|
# Modules that need shared clients or API keys
|
|
_metadata_fingerprinter = MetadataFingerprinter(
|
|
dexscreener_client=dex_client,
|
|
birdeye_api_key=cfg.birdeye_api_key,
|
|
)
|
|
_sentiment_analyzer = SentimentAnalyzer(
|
|
dexscreener_client=dex_client,
|
|
birdeye_api_key=cfg.birdeye_api_key,
|
|
)
|
|
_pumpfun_analyzer = PumpFunAnalyzer(
|
|
helius_api_key=cfg.helius_api_key,
|
|
dexscreener_client=dex_client,
|
|
)
|
|
|
|
# Modules that don't need external API clients (simple init)
|
|
if _holder_analyzer is None:
|
|
_holder_analyzer = HolderAnalyzer()
|
|
if _bundle_detector is None:
|
|
_bundle_detector = BundleDetector()
|
|
if _exchange_funder is None:
|
|
_exchange_funder = ExchangeFunderDetector()
|
|
if _liquidity_verifier is None:
|
|
_liquidity_verifier = LiquidityVerifier()
|
|
if _dev_reputation is None:
|
|
_dev_reputation = DevReputationEngine()
|
|
if _wash_trading is None:
|
|
_wash_trading = WashTradingDetector()
|
|
if _honeypot_detector is None:
|
|
_honeypot_detector = HoneypotDetector()
|
|
if _contract_authority is None:
|
|
_contract_authority = ContractAuthorityScanner()
|
|
if _mev_detector is None:
|
|
_mev_detector = MEVDetector()
|
|
if _flash_loan_detector is None:
|
|
_flash_loan_detector = FlashLoanDetector()
|
|
if _pump_dump_detector is None:
|
|
_pump_dump_detector = PumpDumpDetector()
|
|
if _oracle_manipulation is None:
|
|
_oracle_manipulation = OracleManipulationDetector()
|
|
if _governance_attack is None:
|
|
_governance_attack = GovernanceAttackDetector()
|
|
if _proxy_detector is None:
|
|
_proxy_detector = ProxyDetector()
|
|
|
|
# Tier 3 modules
|
|
if _static_analyzer is None:
|
|
_static_analyzer = StaticAnalyzer()
|
|
if _decompiler_analyzer is None:
|
|
_decompiler_analyzer = DecompilerAnalyzer()
|
|
if _address_labeler is None:
|
|
_address_labeler = AddressLabeler()
|
|
|
|
# Tier 4 modules
|
|
if _fund_flow_visualizer is None:
|
|
_fund_flow_visualizer = FundFlowVisualizer()
|
|
if _contract_diff is None:
|
|
_contract_diff = ContractDiffAnalyzer()
|
|
|
|
# Guilt by Association
|
|
if _guilt_association is None:
|
|
_guilt_association = GuiltAssociationAnalyzer()
|
|
|
|
# New modules
|
|
if _sleep_cycle is None:
|
|
_sleep_cycle = SleepCycleAnalyzer()
|
|
if _social_velocity is None:
|
|
_social_velocity = SocialVelocityAnalyzer()
|
|
if _bytecode_similarity is None:
|
|
_bytecode_similarity = BytecodeSimilarityAnalyzer()
|
|
if _block_zero_sniper is None:
|
|
_block_zero_sniper = BlockZeroSniperAnalyzer()
|
|
if _gas_trace is None:
|
|
_gas_trace = GasTraceAnalyzer()
|
|
|
|
|
|
# ─── Risk score extraction helpers ────────────────────────────────────
|
|
|
|
|
|
def _extract_risk_hhi(report: HHIReport) -> float:
|
|
"""Map HHI value to 0-100 risk score."""
|
|
hhi = report.hhi
|
|
if hhi < 1500:
|
|
return min(hhi / 1500 * 30, 30) # 0-30 range
|
|
elif hhi < 3000:
|
|
return 30 + (hhi - 1500) / 1500 * 30 # 30-60
|
|
elif hhi < 5000:
|
|
return 60 + (hhi - 3000) / 2000 * 20 # 60-80
|
|
else:
|
|
return min(80 + (hhi - 5000) / 5000 * 20, 100) # 80-100
|
|
|
|
|
|
def _extract_risk_bundle(report: BundleReport) -> float:
|
|
"""Bundle report risk score - use bundle_risk_score if available, else heuristic."""
|
|
if hasattr(report, "risk_score"):
|
|
return float(report.risk_score)
|
|
bundle_count = len(report.bundles) if hasattr(report, "bundles") and report.bundles else 0
|
|
return min(bundle_count * 15, 100)
|
|
|
|
|
|
def _extract_risk_exchange_fund(report: ExchangeFundReport) -> float:
|
|
"""Exchange funding risk - higher CEX-funded ratio = more suspicious."""
|
|
if hasattr(report, "cex_funded_percentage"):
|
|
return min(float(report.cex_funded_percentage), 100)
|
|
if hasattr(report, "risk_score"):
|
|
return float(report.risk_score)
|
|
return 0.0
|
|
|
|
|
|
def _extract_risk_lock(report: LockReport) -> float:
|
|
"""Lock risk - unlocked/no lock = 80, locked = low risk."""
|
|
if hasattr(report, "risk_score"):
|
|
return float(report.risk_score)
|
|
if hasattr(report, "is_locked") and not report.is_locked:
|
|
return 80.0
|
|
return 10.0
|
|
|
|
|
|
def _extract_risk_dev(report: DevReputationReport) -> float:
|
|
"""Dev reputation risk - serial ruggers = high risk."""
|
|
if hasattr(report, "risk_score"):
|
|
return float(report.risk_score)
|
|
if hasattr(report, "rug_count"):
|
|
return min(int(report.rug_count) * 25, 100)
|
|
return 0.0
|
|
|
|
|
|
def _extract_risk_wash_trading(report: WashTradingReport) -> float:
|
|
"""Wash trading risk score."""
|
|
if hasattr(report, "risk_score"):
|
|
return float(report.risk_score)
|
|
if hasattr(report, "wash_trading_probability"):
|
|
return float(report.wash_trading_probability) * 100
|
|
return 0.0
|
|
|
|
|
|
def _extract_risk_fingerprint(report: FingerprintReport) -> float:
|
|
"""Metadata fingerprint risk - high similarity = higher risk."""
|
|
if hasattr(report, "risk_score"):
|
|
return float(report.risk_score)
|
|
if hasattr(report, "max_similarity"):
|
|
return float(report.max_similarity) * 100
|
|
return 0.0
|
|
|
|
|
|
def _extract_risk_sentiment(report: SentimentReport) -> float:
|
|
"""Sentiment risk - extreme hype = risky."""
|
|
if hasattr(report, "risk_score"):
|
|
return float(report.risk_score)
|
|
return 30.0 # neutral default
|
|
|
|
|
|
def _extract_risk_pumpfun(report: PumpFunReport) -> float:
|
|
"""PumpFun risk - low graduation probability or high bot ratio = risky."""
|
|
if hasattr(report, "risk_score"):
|
|
return float(report.risk_score)
|
|
if hasattr(report, "graduation_probability"):
|
|
return (1.0 - float(report.graduation_probability)) * 100
|
|
return 30.0
|
|
|
|
|
|
def _extract_risk_guilt_association(report: GuiltAssociationReport) -> float:
|
|
"""Guilt association risk - use guilt_score directly."""
|
|
if hasattr(report, "guilt_score"):
|
|
return float(report.guilt_score)
|
|
if hasattr(report, "risk_score"):
|
|
return float(report.risk_score)
|
|
return 0.0
|
|
|
|
|
|
def _extract_risk_sleep_cycle(report) -> float:
|
|
"""Sleep cycle risk - use sleep_score directly."""
|
|
if hasattr(report, "sleep_score"):
|
|
return float(report.sleep_score)
|
|
return 0.0
|
|
|
|
|
|
def _extract_risk_social_velocity(report) -> float:
|
|
"""Social velocity risk - use velocity_score directly."""
|
|
if hasattr(report, "velocity_score"):
|
|
return float(report.velocity_score)
|
|
return 0.0
|
|
|
|
|
|
def _extract_risk_bytecode_similarity(report) -> float:
|
|
"""Bytecode similarity risk - exact match = 100."""
|
|
if hasattr(report, "exact_match") and report.exact_match:
|
|
return 100.0
|
|
if hasattr(report, "similarity_score"):
|
|
return float(report.similarity_score)
|
|
return 0.0
|
|
|
|
|
|
def _extract_risk_block_zero(report) -> float:
|
|
"""Block zero sniper risk - farm confidence * 100."""
|
|
if hasattr(report, "farm_confidence"):
|
|
return float(report.farm_confidence) * 100
|
|
return 0.0
|
|
|
|
|
|
def _extract_risk_gas_trace(report) -> float:
|
|
"""Gas trace - dead end = 100, cex funded = 0."""
|
|
if hasattr(report, "risk_score"):
|
|
return float(report.risk_score)
|
|
if hasattr(report, "dead_end") and report.dead_end:
|
|
return 100.0
|
|
if hasattr(report, "cex_funded") and report.cex_funded:
|
|
return 10.0
|
|
return 30.0
|
|
|
|
|
|
def _compute_composite(scores: dict[str, float]) -> tuple:
|
|
"""Weighted composite risk score → (score, level).
|
|
|
|
Weights reflect each module's importance for rug-pull detection.
|
|
"""
|
|
weights = {
|
|
"holder_analysis": 0.20,
|
|
"bundle_detection": 0.20,
|
|
"exchange_funding": 0.10,
|
|
"liquidity_verification": 0.15,
|
|
"dev_reputation": 0.15,
|
|
"wash_trading": 0.08,
|
|
"metadata_fingerprint": 0.04,
|
|
"sentiment": 0.04,
|
|
"pumpfun_analysis": 0.04,
|
|
"guilt_association": 0.06,
|
|
"sleep_cycle": 0.04,
|
|
"social_velocity": 0.03,
|
|
"bytecode_similarity": 0.08,
|
|
"block_zero_sniper": 0.08,
|
|
"gas_trace": 0.06,
|
|
}
|
|
total = 0.0
|
|
weight_used = 0.0
|
|
for module, score in scores.items():
|
|
w = weights.get(module, 0.05)
|
|
total += score * w
|
|
weight_used += w
|
|
|
|
# Normalise if not all modules ran
|
|
composite = total / weight_used if weight_used > 0 else 0.0
|
|
|
|
if composite < 25:
|
|
level = "low"
|
|
elif composite < 50:
|
|
level = "moderate"
|
|
elif composite < 75:
|
|
level = "high"
|
|
else:
|
|
level = "critical"
|
|
|
|
return round(composite, 1), level
|
|
|
|
|
|
# ─── Red-flag collection ──────────────────────────────────────────────
|
|
|
|
|
|
def _collect_red_flags(report_dict: dict | None, module_name: str) -> list[str]:
|
|
"""Pull top red-flag strings from each module's dict result."""
|
|
flags: list[str] = []
|
|
if not report_dict:
|
|
return flags
|
|
|
|
# Common patterns: "red_flags", "warnings", "flags"
|
|
for key in ("red_flags", "warnings", "flags", "risks"):
|
|
items = report_dict.get(key, [])
|
|
if isinstance(items, (list, tuple)):
|
|
for item in items[:5]:
|
|
text = str(item) if not isinstance(item, str) else item
|
|
flags.append(f"[{module_name}] {text}")
|
|
|
|
# If risk_level is high/critical, promote
|
|
rl = report_dict.get("risk_level", "")
|
|
if rl in ("high", "critical"):
|
|
flags.append(f"[{module_name}] risk_level={rl}")
|
|
return flags
|
|
|
|
|
|
# ─── Main pipeline ────────────────────────────────────────────────────
|
|
|
|
|
|
async def run_sentinel_scan(
|
|
token_address: str,
|
|
chain: str = "solana",
|
|
dev_address: str | None = None,
|
|
config: SentinelConfig | None = None,
|
|
) -> SentinelReport:
|
|
"""Run all 21 SENTINEL modules concurrently with graceful degradation.
|
|
|
|
Args:
|
|
token_address: Token contract address to scan.
|
|
chain: Blockchain (solana, base, ethereum, etc.).
|
|
dev_address: Optional deployer/creator wallet for dev reputation.
|
|
config: Optional SentinelConfig with API keys and shared clients.
|
|
If None, uses environment variables for keys.
|
|
|
|
Returns:
|
|
SentinelReport with per-module results, composite risk score, and flags.
|
|
"""
|
|
# Ensure modules are initialized with correct config
|
|
_ensure_modules(config)
|
|
|
|
report = SentinelReport(token_address=token_address, chain=chain)
|
|
|
|
# ── Build coroutine list ──────────────────────────────────────
|
|
coros = {}
|
|
|
|
coros["holder_analysis"] = _holder_analyzer.analyze(token_address, chain)
|
|
coros["bundle_detection"] = _bundle_detector.detect_bundles(token_address, chain)
|
|
coros["exchange_funding"] = _exchange_funder.analyze(token_address, chain)
|
|
|
|
async def _liquidity():
|
|
return await _liquidity_verifier.verify_lock(token_address, chain)
|
|
|
|
coros["liquidity_verification"] = _liquidity()
|
|
|
|
if dev_address:
|
|
|
|
async def _dev():
|
|
return await _dev_reputation.analyze(dev_address, chains=[chain])
|
|
|
|
coros["dev_reputation"] = _dev()
|
|
else:
|
|
# Skip dev reputation if no address provided
|
|
report.errors["dev_reputation"] = "skipped: no dev_address provided"
|
|
coros["dev_reputation"] = None
|
|
|
|
coros["wash_trading"] = _wash_trading.analyze(token_address, chain)
|
|
coros["metadata_fingerprint"] = _metadata_fingerprinter.analyze_token_metadata(token_address, chain)
|
|
coros["sentiment"] = _sentiment_analyzer.analyze(token_address, chain)
|
|
coros["honeypot_detection"] = _honeypot_detector.analyze(token_address, chain)
|
|
coros["contract_authority"] = _contract_authority.analyze(token_address, chain)
|
|
coros["mev_detection"] = _mev_detector.analyze(token_address, chain)
|
|
|
|
# Tier 2 scanners
|
|
coros["flash_loan_detection"] = _flash_loan_detector.analyze(token_address, chain)
|
|
coros["pump_dump_detection"] = _pump_dump_detector.analyze(token_address, chain)
|
|
coros["oracle_manipulation"] = _oracle_manipulation.analyze(token_address, chain)
|
|
coros["governance_attack"] = _governance_attack.analyze(token_address, chain)
|
|
coros["proxy_detection"] = _proxy_detector.analyze(token_address, chain)
|
|
|
|
# Tier 3 scanners
|
|
coros["static_analysis"] = _static_analyzer.analyze(token_address, chain)
|
|
coros["decompiler_analysis"] = _decompiler_analyzer.analyze(token_address, chain)
|
|
coros["address_labels"] = _address_labeler.analyze(token_address, chain)
|
|
|
|
# ── Wallet Memory Bank (replaces scattered wallet lookups) ───
|
|
if _WALLET_MEMORY_AVAILABLE and dev_address:
|
|
|
|
async def _wallet_intel():
|
|
engine = get_wallet_engine()
|
|
return await engine.get_deployer_intelligence(dev_address, chain)
|
|
|
|
coros["wallet_intel"] = _wallet_intel()
|
|
else:
|
|
coros["wallet_intel"] = None
|
|
|
|
# Tier 4 - Visualization
|
|
coros["fund_flow"] = _fund_flow_visualizer.analyze(token_address, chain)
|
|
coros["contract_diff"] = _contract_diff.analyze(token_address, chain)
|
|
|
|
# Guilt by Association (requires deployer address)
|
|
if dev_address:
|
|
|
|
async def _guilt():
|
|
return await _guilt_association.analyze(dev_address, chain)
|
|
|
|
coros["guilt_association"] = _guilt()
|
|
else:
|
|
report.errors["guilt_association"] = "skipped: no dev_address provided"
|
|
coros["guilt_association"] = None
|
|
|
|
# Sleep Cycle (requires deployer deployment timestamps)
|
|
if dev_address:
|
|
|
|
async def _sleep():
|
|
# This module needs deployment timestamps, pass empty for now
|
|
return await _sleep_cycle.analyze([])
|
|
|
|
coros["sleep_cycle"] = _sleep()
|
|
else:
|
|
coros["sleep_cycle"] = None
|
|
|
|
# Social Velocity (runs on social mention data, pass empty)
|
|
async def _social():
|
|
return await _social_velocity.analyze([])
|
|
|
|
coros["social_velocity"] = _social()
|
|
|
|
# Bytecode Similarity (needs on-chain bytecode)
|
|
async def _bytecode():
|
|
return await _bytecode_similarity.analyze(token_address, chain)
|
|
|
|
coros["bytecode_similarity"] = _bytecode()
|
|
|
|
# Block Zero Sniper
|
|
async def _blockzero():
|
|
return await _block_zero_sniper.analyze(token_address, chain)
|
|
|
|
coros["block_zero_sniper"] = _blockzero()
|
|
|
|
# Gas Trace (requires deployer address)
|
|
if dev_address:
|
|
|
|
async def _gas():
|
|
return await _gas_trace.analyze(dev_address, chain)
|
|
|
|
coros["gas_trace"] = _gas()
|
|
else:
|
|
coros["gas_trace"] = None
|
|
|
|
# PumpFun is Solana-only
|
|
if chain.lower() == "solana":
|
|
coros["pumpfun_analysis"] = _pumpfun_analyzer.analyze(token_address)
|
|
else:
|
|
report.errors["pumpfun_analysis"] = f"skipped: PumpFun analyzer is Solana-only (chain={chain})"
|
|
coros["pumpfun_analysis"] = None
|
|
|
|
# ── Run all modules concurrently with graceful degradation ────
|
|
keys = [k for k, v in coros.items() if v is not None]
|
|
tasks = {k: asyncio.create_task(coros[k]) for k in keys}
|
|
|
|
results: dict[str, Any] = {}
|
|
for k, task in tasks.items():
|
|
try:
|
|
results[k] = await task
|
|
except Exception as exc:
|
|
logger.warning(f"SENTINEL module {k} failed for {token_address}: {exc}")
|
|
results[k] = None
|
|
report.errors[k] = str(exc)[:200]
|
|
|
|
# ── Convert dataclass results to dicts ───────────────────────
|
|
risk_scores: dict[str, float] = {}
|
|
|
|
extractors = {
|
|
"holder_analysis": _extract_risk_hhi,
|
|
"bundle_detection": _extract_risk_bundle,
|
|
"exchange_funding": _extract_risk_exchange_fund,
|
|
"liquidity_verification": _extract_risk_lock,
|
|
"dev_reputation": _extract_risk_dev,
|
|
"wash_trading": _extract_risk_wash_trading,
|
|
"metadata_fingerprint": _extract_risk_fingerprint,
|
|
"sentiment": _extract_risk_sentiment,
|
|
"pumpfun_analysis": _extract_risk_pumpfun,
|
|
"honeypot_detection": lambda r: float(getattr(r, "risk_score", 0)),
|
|
"contract_authority": lambda r: float(getattr(r, "risk_score", 0)),
|
|
"mev_detection": lambda r: float(getattr(r, "mev_risk_score", 0)),
|
|
"flash_loan_detection": lambda r: float(getattr(r, "risk_score", 0)),
|
|
"pump_dump_detection": lambda r: float(getattr(r, "risk_score", 0)),
|
|
"oracle_manipulation": lambda r: float(getattr(r, "risk_score", 0)),
|
|
"governance_attack": lambda r: float(getattr(r, "risk_score", 0)),
|
|
"proxy_detection": lambda r: float(getattr(r, "risk_score", 0)),
|
|
"static_analysis": lambda r: float(getattr(r, "risk_score", 0)),
|
|
"decompiler_analysis": lambda r: float(getattr(r, "risk_score", 0)),
|
|
"address_labels": lambda r: float(getattr(r, "risk_score", 0)),
|
|
"wallet_intel": lambda r: float(r.get("risk_score", 0)) if isinstance(r, dict) else 0.0,
|
|
"fund_flow": lambda r: float(getattr(r, "risk_score", 0)),
|
|
"contract_diff": lambda r: float(getattr(r, "risk_score", 0)),
|
|
"guilt_association": _extract_risk_guilt_association,
|
|
"sleep_cycle": _extract_risk_sleep_cycle,
|
|
"social_velocity": _extract_risk_social_velocity,
|
|
"bytecode_similarity": _extract_risk_bytecode_similarity,
|
|
"block_zero_sniper": _extract_risk_block_zero,
|
|
"gas_trace": _extract_risk_gas_trace,
|
|
}
|
|
|
|
# Module name → field name on SentinelReport
|
|
field_map = {
|
|
"holder_analysis": "holder_analysis",
|
|
"bundle_detection": "bundle_detection",
|
|
"exchange_funding": "exchange_funding",
|
|
"liquidity_verification": "liquidity_verification",
|
|
"dev_reputation": "dev_reputation",
|
|
"wash_trading": "wash_trading",
|
|
"metadata_fingerprint": "metadata_fingerprint",
|
|
"sentiment": "sentiment",
|
|
"pumpfun_analysis": "pumpfun_analysis",
|
|
"honeypot_detection": "honeypot_detection",
|
|
"contract_authority": "contract_authority",
|
|
"mev_detection": "mev_detection",
|
|
"flash_loan_detection": "flash_loan_detection",
|
|
"pump_dump_detection": "pump_dump_detection",
|
|
"oracle_manipulation": "oracle_manipulation",
|
|
"governance_attack": "governance_attack",
|
|
"proxy_detection": "proxy_detection",
|
|
"static_analysis": "static_analysis",
|
|
"decompiler_analysis": "decompiler_analysis",
|
|
"address_labels": "address_labels",
|
|
"wallet_intel": "wallet_intel",
|
|
"fund_flow": "fund_flow",
|
|
"contract_diff": "contract_diff",
|
|
"guilt_association": "guilt_association",
|
|
"sleep_cycle": "sleep_cycle",
|
|
"social_velocity": "social_velocity",
|
|
"bytecode_similarity": "bytecode_similarity",
|
|
"block_zero_sniper": "block_zero_sniper",
|
|
"gas_trace": "gas_trace",
|
|
}
|
|
|
|
for module_name, raw_result in results.items():
|
|
dict_result = dataclass_to_dict(raw_result) if raw_result is not None else None
|
|
setattr(report, field_map[module_name], dict_result)
|
|
report.modules_run.append(module_name)
|
|
|
|
# Extract risk score
|
|
if raw_result is not None and module_name in extractors:
|
|
try:
|
|
risk_scores[module_name] = extractors[module_name](raw_result)
|
|
except Exception as e:
|
|
logger.debug(f"Risk score extraction failed for {module_name}: {e}")
|
|
|
|
# Collect red flags
|
|
flags = _collect_red_flags(dict_result, module_name)
|
|
report.red_flags.extend(flags)
|
|
|
|
# ── Compute composite risk ───────────────────────────────────
|
|
report.module_risk_scores = risk_scores
|
|
composite, level = _compute_composite(risk_scores)
|
|
report.composite_risk_score = composite
|
|
report.risk_level = level
|
|
|
|
# ── Data flywheel: feed results back to Wallet Memory Bank ───
|
|
if _WALLET_MEMORY_AVAILABLE and dev_address and composite > 0:
|
|
try:
|
|
engine = get_wallet_engine()
|
|
await engine.flag_deployer(
|
|
address=dev_address,
|
|
chain=chain,
|
|
reason="sentinel_scan",
|
|
token=token_address,
|
|
score=composite,
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Wallet Memory Bank feedback failed: {e}")
|
|
|
|
return report
|
|
|
|
|
|
# ─── Standalone single-module runners (for individual endpoint use) ──
|
|
|
|
|
|
async def run_holder_analysis(token_address: str, chain: str, config: SentinelConfig | None = None) -> dict[str, Any]:
|
|
"""Run HolderAnalyzer only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _holder_analyzer.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_bundle_detection(token_address: str, chain: str, config: SentinelConfig | None = None) -> dict[str, Any]:
|
|
"""Run BundleDetector only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _bundle_detector.detect_bundles(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_exchange_funding(token_address: str, chain: str, config: SentinelConfig | None = None) -> dict[str, Any]:
|
|
"""Run ExchangeFunderDetector only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _exchange_funder.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_liquidity_verification(
|
|
token_address: str, chain: str, config: SentinelConfig | None = None
|
|
) -> dict[str, Any]:
|
|
"""Run LiquidityVerifier only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _liquidity_verifier.verify_lock(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_dev_reputation(
|
|
dev_wallet: str, chains: list[str] | None = None, config: SentinelConfig | None = None
|
|
) -> dict[str, Any]:
|
|
"""Run DevReputationEngine only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _dev_reputation.analyze(dev_wallet, chains)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_wash_trading(token_address: str, chain: str, config: SentinelConfig | None = None) -> dict[str, Any]:
|
|
"""Run WashTradingDetector only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _wash_trading.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_metadata_fingerprint(
|
|
token_address: str, chain: str, config: SentinelConfig | None = None
|
|
) -> dict[str, Any]:
|
|
"""Run MetadataFingerprinter only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _metadata_fingerprinter.analyze_token_metadata(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_sentiment(token_address: str, chain: str, config: SentinelConfig | None = None) -> dict[str, Any]:
|
|
"""Run SentimentAnalyzer only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _sentiment_analyzer.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_pumpfun_analysis(token_address: str, config: SentinelConfig | None = None) -> dict[str, Any]:
|
|
"""Run PumpFunAnalyzer only (Solana only), return dict."""
|
|
_ensure_modules(config)
|
|
result = await _pumpfun_analyzer.analyze(token_address)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_honeypot_detection(
|
|
token_address: str, chain: str, config: SentinelConfig | None = None
|
|
) -> dict[str, Any]:
|
|
"""Run HoneypotDetector only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _honeypot_detector.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_contract_authority(
|
|
token_address: str, chain: str, config: SentinelConfig | None = None
|
|
) -> dict[str, Any]:
|
|
"""Run ContractAuthorityScanner only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _contract_authority.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_mev_detection(token_address: str, chain: str, config: SentinelConfig | None = None) -> dict[str, Any]:
|
|
"""Run MEVDetector only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _mev_detector.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_flash_loan_detection(
|
|
token_address: str, chain: str, config: SentinelConfig | None = None
|
|
) -> dict[str, Any]:
|
|
"""Run FlashLoanDetector only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _flash_loan_detector.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_pump_dump_detection(
|
|
token_address: str, chain: str, config: SentinelConfig | None = None
|
|
) -> dict[str, Any]:
|
|
"""Run PumpDumpDetector only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _pump_dump_detector.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_oracle_manipulation(
|
|
token_address: str, chain: str, config: SentinelConfig | None = None
|
|
) -> dict[str, Any]:
|
|
"""Run OracleManipulationDetector only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _oracle_manipulation.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_governance_attack(token_address: str, chain: str, config: SentinelConfig | None = None) -> dict[str, Any]:
|
|
"""Run GovernanceAttackDetector only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _governance_attack.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_proxy_detection(token_address: str, chain: str, config: SentinelConfig | None = None) -> dict[str, Any]:
|
|
"""Run ProxyDetector only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _proxy_detector.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_static_analysis(token_address: str, chain: str, config: SentinelConfig | None = None) -> dict[str, Any]:
|
|
"""Run StaticAnalyzer only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _static_analyzer.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_decompiler_analysis(
|
|
token_address: str, chain: str, config: SentinelConfig | None = None
|
|
) -> dict[str, Any]:
|
|
"""Run DecompilerAnalyzer only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _decompiler_analyzer.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_address_labels(token_address: str, chain: str, config: SentinelConfig | None = None) -> dict[str, Any]:
|
|
"""Run AddressLabeler only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _address_labeler.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_fund_flow(token_address: str, chain: str, config: SentinelConfig | None = None) -> dict[str, Any]:
|
|
"""Run FundFlowVisualizer only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _fund_flow_visualizer.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|
|
|
|
|
|
async def run_contract_diff(token_address: str, chain: str, config: SentinelConfig | None = None) -> dict[str, Any]:
|
|
"""Run ContractDiffAnalyzer only, return dict."""
|
|
_ensure_modules(config)
|
|
result = await _contract_diff.analyze(token_address, chain)
|
|
return dataclass_to_dict(result)
|