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)
475 lines
18 KiB
Python
475 lines
18 KiB
Python
"""
|
|
SENTINEL Contract Diff - bytecode hash comparison against known rug patterns
|
|
==============================================================================
|
|
Compares a token's contract bytecode/ABI against a database of known rug contracts.
|
|
Detects clones, forks, and near-identical contracts by hashing function selectors,
|
|
bytecode sections, and metadata patterns.
|
|
|
|
Key features:
|
|
- EVM bytecode hashing (creation code + runtime code)
|
|
- Function selector fingerprinting (4byte directory style)
|
|
- Known-rug bytecode database via RAG
|
|
- Similarity scoring against known malicious contracts
|
|
- Contract clone detection (same creator, same code, different name)
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger("contract_diff")
|
|
|
|
|
|
@dataclass
|
|
class ContractDiffReport:
|
|
"""Result of contract diff analysis."""
|
|
|
|
token_address: str
|
|
chain: str
|
|
risk_score: float = 0.0
|
|
risk_level: str = "LOW"
|
|
|
|
# Contract identification
|
|
is_verified: bool = False
|
|
contract_name: str | None = None
|
|
bytecode_hash: str | None = None
|
|
runtime_bytecode_hash: str | None = None
|
|
|
|
# Function selectors
|
|
function_selectors: list[str] = field(default_factory=list)
|
|
selector_count: int = 0
|
|
|
|
# Dangerous function signatures found
|
|
dangerous_functions: list[str] = field(default_factory=list)
|
|
|
|
# Clone detection
|
|
is_clone: bool = False
|
|
clone_of: str | None = None # Address of matching contract
|
|
clone_similarity: float = 0.0
|
|
|
|
# Known rug matches
|
|
known_rug_matches: list[dict[str, Any]] = field(default_factory=list)
|
|
|
|
# RAG citations
|
|
citations: list[dict[str, Any]] = field(default_factory=list)
|
|
|
|
warnings: list[str] = field(default_factory=list)
|
|
|
|
|
|
# Dangerous function selectors (4-byte signatures of known rug functions)
|
|
DANGEROUS_SELECTORS = {
|
|
"0x3ccfd60b": "withdraw()",
|
|
"0x2e1a7d4d": "withdraw(uint256)",
|
|
"0x51cff8d9": "withdrawAll()",
|
|
"0x1e83409a": "emergencyWithdraw()",
|
|
"0x715018a6": "renounceOwnership()",
|
|
"0xf2fde38b": "transferOwnership(address)",
|
|
"0x8da5cb5b": "owner()",
|
|
"0x7f4e2f42": "setOwner(address)",
|
|
"0x42966c68": "burn(uint256)",
|
|
"0xa9059cbb": "transfer(address,uint256)",
|
|
"0x095ea7b3": "approve(address,uint256)",
|
|
"0x23b872dd": "transferFrom(address,address,uint256)",
|
|
"0x40c10f19": "mint(address,uint256)",
|
|
"0xa0712d68": "mint(uint256)",
|
|
"0x6a627842": "mint(address)",
|
|
"0x00fdd58e": "mint(address,uint256)",
|
|
"0x4f1ef286": "upgradeToAndCall(address,bytes)",
|
|
"0x3659cfe6": "upgradeTo(address)",
|
|
"0x5c60da1b": "implementation()",
|
|
"0x8f283970": "drain()",
|
|
"0x3f4ba83a": "unpause()",
|
|
"0x8456cb59": "pause()",
|
|
"0xf48f7360": "setFee(uint256)",
|
|
"0x092f3ee0": "setMaxTxAmount(uint256)",
|
|
"0xc8b3bf68": "setWalletLimit(uint256)",
|
|
"0x6080c494": "disableLimits()",
|
|
"0xd8a9be58": "enableTrading()",
|
|
"0x4a8b62d1": "setSwapBackSettings(uint256,bool)",
|
|
}
|
|
|
|
# Patterns that strongly indicate rug contracts
|
|
RUG_BYTECODE_PATTERNS = [
|
|
# Hidden mint functions
|
|
rb"hidden\s+mint",
|
|
rb"airdropClaim",
|
|
rb"airdrop_register",
|
|
# Fee manipulation
|
|
rb"setFeeRate",
|
|
rb"setTaxFee",
|
|
rb"setMaxFee",
|
|
# Anti-selling mechanisms
|
|
rb"isBlacklisted",
|
|
rb"_blacklist",
|
|
rb"blacklisted",
|
|
rb"tradingOpen",
|
|
rb"tradingEnabled",
|
|
rb"launchedAt",
|
|
]
|
|
|
|
|
|
class ContractDiffAnalyzer:
|
|
"""Compare contract bytecode against known rug patterns and clone databases."""
|
|
|
|
def __init__(self):
|
|
self._initialized = False
|
|
self._rug_hashes: dict[str, dict[str, Any]] = {}
|
|
self._selector_map: dict[str, list[str]] = {} # selector -> contract addresses
|
|
|
|
async def _ensure_init(self):
|
|
"""Load known rug patterns from RAG on first use."""
|
|
if self._initialized:
|
|
return
|
|
|
|
try:
|
|
from app.domains.scanners.rag_citations import query_rag_citations
|
|
|
|
# Load known rug contract hashes from RAG
|
|
cits = await query_rag_citations(
|
|
topic="known rug contract bytecode hash clone",
|
|
chain="ethereum",
|
|
scanner_type="contract_diff",
|
|
max_citations=20,
|
|
min_similarity=0.3,
|
|
)
|
|
for c in cits:
|
|
meta = c.get("metadata", {})
|
|
addr = meta.get("address", "")
|
|
bhash = meta.get("bytecode_hash", "")
|
|
if addr and bhash:
|
|
self._rug_hashes[bhash] = {
|
|
"address": addr,
|
|
"name": meta.get("name", "unknown"),
|
|
"flagged_as": meta.get("flagged_as", "rug"),
|
|
"similarity": c.get("similarity", 0),
|
|
"collection": c.get("collection", ""),
|
|
"reference": c.get("reference", ""),
|
|
}
|
|
|
|
self._initialized = True
|
|
if self._rug_hashes:
|
|
logger.info(f"Loaded {len(self._rug_hashes)} known rug bytecode hashes")
|
|
except Exception as e:
|
|
logger.warning(f"Could not load rug hashes: {e}")
|
|
self._initialized = True # Don't retry on every call
|
|
|
|
def _hash_bytecode(self, bytecode: str) -> str:
|
|
"""Hash bytecode for comparison. Strip metadata hash first."""
|
|
# Remove CBOR metadata from end of bytecode (introduced in Solidity 0.6+)
|
|
# The metadata hash is after the last STOP (0x00) opcode boundary
|
|
clean = bytecode.lower().replace("0x", "")
|
|
|
|
# Try to strip Solidity metadata (CBOR-encoded)
|
|
# Look for known metadata markers
|
|
for marker in ["a1", "a2", "65766d", "627a6574"]: # CBOR + solc markers
|
|
idx = clean.rfind(marker)
|
|
if idx > len(clean) // 2: # Only strip if near the end
|
|
clean = clean[:idx]
|
|
|
|
return hashlib.sha256(clean.encode()).hexdigest()
|
|
|
|
def _extract_selectors(self, bytecode: str) -> list[str]:
|
|
"""Extract 4-byte function selectors from bytecode using PUSH4 pattern."""
|
|
selectors = []
|
|
clean = bytecode.lower().replace("0x", "")
|
|
|
|
# PUSH4 = 0x63, followed by 4 bytes, then typically EQ (0x14) or other comparison
|
|
pattern = re.compile(r"63([0-9a-f]{8})14")
|
|
for m in pattern.finditer(clean):
|
|
sel = "0x" + m.group(1)
|
|
if sel not in selectors:
|
|
selectors.append(sel)
|
|
|
|
return selectors
|
|
|
|
def _find_dangerous_functions(self, selectors: list[str]) -> list[str]:
|
|
"""Check selectors against known dangerous function signatures."""
|
|
dangerous = []
|
|
for sel in selectors:
|
|
name = DANGEROUS_SELECTORS.get(sel)
|
|
if name:
|
|
dangerous.append(f"{sel} → {name}")
|
|
return dangerous
|
|
|
|
def _scan_rug_patterns(self, bytecode: str) -> list[str]:
|
|
"""Scan bytecode for known rug contract patterns."""
|
|
found = []
|
|
raw = bytecode.lower().encode() if not bytecode.startswith("0x") else bytecode[2:].lower().encode()
|
|
|
|
for pattern in RUG_BYTECODE_PATTERNS:
|
|
try:
|
|
if pattern.lower() in raw:
|
|
found.append(pattern.decode(errors="replace"))
|
|
except Exception:
|
|
pass
|
|
return found
|
|
|
|
def _compute_similarity(self, selectors_a: list[str], selectors_b: list[str]) -> float:
|
|
"""Compute Jaccard similarity between two selector sets."""
|
|
if not selectors_a or not selectors_b:
|
|
return 0.0
|
|
set_a = set(selectors_a)
|
|
set_b = set(selectors_b)
|
|
intersection = set_a & set_b
|
|
union = set_a | set_b
|
|
return len(intersection) / len(union) if union else 0.0
|
|
|
|
async def analyze(self, token_address: str, chain: str) -> ContractDiffReport:
|
|
"""Run contract diff analysis on a token."""
|
|
await self._ensure_init()
|
|
|
|
report = ContractDiffReport(token_address=token_address, chain=chain)
|
|
|
|
try:
|
|
bytecode = await self._fetch_bytecode(token_address, chain)
|
|
if not bytecode:
|
|
report.warnings.append("Could not fetch contract bytecode")
|
|
return report
|
|
|
|
# Hash the bytecode
|
|
report.bytecode_hash = self._hash_bytecode(bytecode)
|
|
report.runtime_bytecode_hash = hashlib.sha256(bytecode.lower().replace("0x", "").encode()).hexdigest()
|
|
|
|
# Extract function selectors
|
|
report.function_selectors = self._extract_selectors(bytecode)
|
|
report.selector_count = len(report.function_selectors)
|
|
|
|
# Check for dangerous functions
|
|
report.dangerous_functions = self._find_dangerous_functions(report.function_selectors)
|
|
|
|
# Check against known rug patterns
|
|
rug_patterns = self._scan_rug_patterns(bytecode)
|
|
|
|
# Check bytecode hash against known rug contracts
|
|
if report.bytecode_hash in self._rug_hashes:
|
|
match = self._rug_hashes[report.bytecode_hash]
|
|
report.is_clone = True
|
|
report.clone_of = match["address"]
|
|
report.clone_similarity = 1.0
|
|
report.known_rug_matches.append(
|
|
{
|
|
"type": "exact_hash_match",
|
|
"address": match["address"],
|
|
"name": match.get("name", "unknown"),
|
|
"flagged_as": match.get("flagged_as", "rug"),
|
|
"collection": match.get("collection", ""),
|
|
}
|
|
)
|
|
|
|
# Compute selector similarity against known rug contracts
|
|
for bhash, info in self._rug_hashes.items():
|
|
if bhash == report.bytecode_hash:
|
|
continue # Already handled above
|
|
other_selectors = info.get("selectors", [])
|
|
if other_selectors:
|
|
sim = self._compute_similarity(report.function_selectors, other_selectors)
|
|
if sim > 0.7: # High similarity threshold
|
|
report.known_rug_matches.append(
|
|
{
|
|
"type": "selector_similarity",
|
|
"address": info["address"],
|
|
"name": info.get("name", "unknown"),
|
|
"flagged_as": info.get("flagged_as", "rug"),
|
|
"similarity": round(sim, 3),
|
|
"collection": info.get("collection", ""),
|
|
}
|
|
)
|
|
if sim > report.clone_similarity:
|
|
report.clone_similarity = sim
|
|
report.clone_of = info["address"]
|
|
|
|
# RAG citations
|
|
try:
|
|
from app.domains.scanners.rag_citations import query_rag_citations
|
|
|
|
report.citations = await query_rag_citations(
|
|
topic=f"contract clone rug pattern {report.bytecode_hash[:8]}",
|
|
chain=chain,
|
|
scanner_type="contract_diff",
|
|
max_citations=3,
|
|
min_similarity=0.3,
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Citation lookup failed: {e}")
|
|
|
|
# Compute risk score
|
|
risk_score = 0.0
|
|
|
|
# Exact hash match = instant high risk
|
|
if report.is_clone and any(m.get("type") == "exact_hash_match" for m in report.known_rug_matches):
|
|
risk_score += 60.0
|
|
|
|
# High selector similarity
|
|
if report.clone_similarity > 0.8:
|
|
risk_score += 30.0
|
|
elif report.clone_similarity > 0.5:
|
|
risk_score += 15.0
|
|
|
|
# Dangerous functions
|
|
danger_weight = {
|
|
"withdraw": 10,
|
|
"withdrawAll": 15,
|
|
"drain": 20,
|
|
"setOwner": 10,
|
|
"mint": 8,
|
|
"upgradeTo": 12,
|
|
}
|
|
for df in report.dangerous_functions:
|
|
for key, weight in danger_weight.items():
|
|
if key.lower() in df.lower():
|
|
risk_score += weight
|
|
break
|
|
|
|
# Rug bytecode patterns
|
|
if rug_patterns:
|
|
risk_score += min(len(rug_patterns) * 5, 25)
|
|
|
|
# Multiple known rug matches
|
|
if len(report.known_rug_matches) >= 3:
|
|
risk_score += 15
|
|
|
|
report.risk_score = min(risk_score, 100)
|
|
|
|
if report.risk_score >= 70:
|
|
report.risk_level = "CRITICAL"
|
|
elif report.risk_score >= 40:
|
|
report.risk_level = "HIGH"
|
|
elif report.risk_score >= 20:
|
|
report.risk_level = "MEDIUM"
|
|
else:
|
|
report.risk_level = "LOW"
|
|
|
|
# Warnings
|
|
if report.is_clone:
|
|
report.warnings.append(
|
|
f"Contract bytecode matches known {report.known_rug_matches[0].get('flagged_as', 'rug')} "
|
|
f"contract at {report.clone_of}"
|
|
)
|
|
if report.clone_similarity > 0.7 and not report.is_clone:
|
|
report.warnings.append(
|
|
f"Contract selectors {report.clone_similarity:.0%} similar to known rug at {report.clone_of}"
|
|
)
|
|
if report.dangerous_functions:
|
|
report.warnings.append(f"Dangerous functions detected: {', '.join(report.dangerous_functions[:3])}")
|
|
if rug_patterns:
|
|
report.warnings.append(f"Rug-pattern bytecode markers found: {', '.join(rug_patterns[:3])}")
|
|
|
|
# Enhance warnings with RAG citations
|
|
try:
|
|
from app.domains.scanners.rag_citations import build_citation_string
|
|
|
|
if report.citations and report.warnings:
|
|
cit_str = build_citation_string(report.citations)
|
|
report.warnings[-1] += f" {cit_str}"
|
|
except Exception:
|
|
pass
|
|
|
|
except Exception as e:
|
|
logger.error(f"Contract diff analysis failed: {e}")
|
|
report.warnings.append(f"Analysis error: {str(e)[:100]}")
|
|
|
|
return report
|
|
|
|
# Chain name → chain ID mapping for EVM chains
|
|
CHAIN_TO_ID = { # noqa: RUF012
|
|
"ethereum": 1,
|
|
"eth": 1,
|
|
"bsc": 56,
|
|
"bnb": 56,
|
|
"polygon": 137,
|
|
"matic": 137,
|
|
"base": 8453,
|
|
"arbitrum": 42161,
|
|
"arb": 42161,
|
|
"optimism": 10,
|
|
"op": 10,
|
|
"avalanche": 43114,
|
|
"avax": 43114,
|
|
"fantom": 250,
|
|
"ftm": 250,
|
|
}
|
|
|
|
async def _fetch_bytecode(self, address: str, chain: str) -> str | None:
|
|
"""Fetch contract bytecode from RPC using consensus client."""
|
|
chain_lower = chain.lower()
|
|
|
|
# Solana: program data via cached consensus RPC
|
|
if chain_lower == "solana":
|
|
try:
|
|
from app.caching_shield.rpc_cache import get_rpc_cache
|
|
|
|
cache = get_rpc_cache()
|
|
result = await cache.get_account_info(address, chain="solana")
|
|
if result and result.value:
|
|
# Solana account data is base64-encoded in value.data[0]
|
|
account_data = result.value
|
|
if isinstance(account_data, dict):
|
|
data_arr = account_data.get("data", [])
|
|
if isinstance(data_arr, list) and data_arr:
|
|
# Return the base64-encoded program data
|
|
return data_arr[0] if data_arr[1] == "base64" else str(data_arr[0])
|
|
return str(account_data)
|
|
except Exception as e:
|
|
logger.debug(f"Solana account info fetch failed: {e}")
|
|
return None
|
|
|
|
# EVM chains: eth_getCode via consensus RPC
|
|
chain_id = self.CHAIN_TO_ID.get(chain_lower)
|
|
if not chain_id:
|
|
logger.warning(f"Unknown EVM chain for bytecode fetch: {chain}")
|
|
return None
|
|
|
|
try:
|
|
from app.caching_shield.rpc_cache import get_rpc_cache
|
|
|
|
cache = get_rpc_cache()
|
|
result = await cache.query_with_cache(
|
|
method="eth_getCode",
|
|
params=[address, "latest"],
|
|
chain=str(chain_id),
|
|
)
|
|
code_hex = result.value if result else None
|
|
if code_hex and code_hex != "0x" and len(str(code_hex)) > 10:
|
|
logger.info(
|
|
f"Got bytecode for {address} on {chain} (confidence={result.confidence}%, sources={result.response_count})"
|
|
)
|
|
return code_hex
|
|
logger.debug(f"eth_getCode returned empty for {address} on {chain}")
|
|
except Exception as e:
|
|
logger.debug(f"Consensus RPC bytecode fetch failed: {e}")
|
|
|
|
# Fallback: direct public RPC call
|
|
try:
|
|
import httpx
|
|
|
|
rpc_urls = {
|
|
1: "https://ethereum-rpc.publicnode.com",
|
|
56: "https://bsc-rpc.publicnode.com",
|
|
137: "https://polygon-rpc.publicnode.com",
|
|
8453: "https://base-rpc.publicnode.com",
|
|
42161: "https://arbitrum-rpc.publicnode.com",
|
|
10: "https://optimism-rpc.publicnode.com",
|
|
}
|
|
url = rpc_urls.get(chain_id)
|
|
if url:
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.post(
|
|
url,
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"method": "eth_getCode",
|
|
"params": [address, "latest"],
|
|
"id": 1,
|
|
},
|
|
)
|
|
data = resp.json()
|
|
code = data.get("result", "0x")
|
|
if code and code != "0x" and len(code) > 10:
|
|
logger.info(f"Got bytecode via fallback RPC for {address}")
|
|
return code
|
|
except Exception as e:
|
|
logger.debug(f"Fallback RPC failed: {e}")
|
|
|
|
return None
|