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)
474 lines
19 KiB
Python
474 lines
19 KiB
Python
"""
|
|
SENTINEL - Bytecode Similarity Hashing
|
|
=======================================
|
|
Hash compiled bytecode of a token and compare against a local DB of known
|
|
scam contracts. Detects recycled / cloned scam contracts via:
|
|
|
|
- Exact SHA-256 hash match → CRITICAL (recycled scam)
|
|
- Partial / fuzzy similarity scoring based on cleaned bytecode structure
|
|
- Cross-chain matching (EVM bytecode + Solana program data)
|
|
|
|
Bytecode is cleaned before hashing:
|
|
- Constructor arguments stripped (everything before init-code start)
|
|
- Solidity CBOR / metadata suffixes removed
|
|
- 0x prefix normalised
|
|
|
|
Database: scam_bytecode_db.json (auto-created, maintained in this directory)
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from app.chain_registry import is_solana
|
|
|
|
logger = logging.getLogger("bytecode_similarity")
|
|
|
|
# ── Constants ──────────────────────────────────────────────────────────
|
|
|
|
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
DB_PATH = os.path.join(_THIS_DIR, "scam_bytecode_db.json")
|
|
|
|
# EVM chain_id lookup (mirrors contract_diff.CHAIN_TO_ID)
|
|
CHAIN_TO_ID: dict[str, int] = {
|
|
"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,
|
|
"linea": 59144,
|
|
"zksync": 324,
|
|
"scroll": 534352,
|
|
"mantle": 5000,
|
|
}
|
|
|
|
# Solana RPC fallback
|
|
SOLANA_RPC = "https://api.mainnet-beta.solana.com"
|
|
|
|
# Known CBOR / Solidity metadata markers to strip (near end of bytecode)
|
|
METADATA_MARKERS = ["a1", "a2", "65766d", "627a6574"]
|
|
|
|
|
|
# ── Standalone Helpers ──────────────────────────────────────────────
|
|
|
|
|
|
def hash_bytecode(hex_str: str) -> str:
|
|
"""Standalone SHA-256 hash of cleaned bytecode.
|
|
|
|
Strips ``0x`` prefix, constructor arguments, and Solidity CBOR metadata
|
|
so that functionally identical contracts produce the same hash.
|
|
"""
|
|
clean = hex_str.lower().strip()
|
|
if clean.startswith("0x"):
|
|
clean = clean[2:]
|
|
|
|
if not clean:
|
|
return hashlib.sha256(b"").hexdigest()
|
|
|
|
# ── Strip constructor arguments ──────────────────────────────────
|
|
# For creation-code (deployed) bytecode, the constructor arguments
|
|
# sit *after* the init code. We strip the tail if there's a known
|
|
# init-code boundary pattern. Heuristic: try to find solidity init
|
|
# code suffix patterns.
|
|
#
|
|
# Smart-contract creation bytecode typically looks like:
|
|
# <init code><runtime code><constructor args>
|
|
# After deployment, *runtime bytecode* is stored on-chain, so the
|
|
# constructor args have already been consumed. We keep runtime
|
|
# bytecode as-is and only strip metadata.
|
|
|
|
# ── Strip Solidity CBOR metadata ─────────────────────────────────
|
|
# Solidity >=0.6.x appends a CBOR-encoded metadata hash. We look
|
|
# for its markers near the end of the hex string.
|
|
for marker in METADATA_MARKERS:
|
|
idx = clean.rfind(marker)
|
|
if idx > len(clean) // 2: # only strip if in the second half
|
|
clean = clean[:idx]
|
|
|
|
return hashlib.sha256(clean.encode()).hexdigest()
|
|
|
|
|
|
# ── Report Dataclass ─────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class BytecodeSimilarityReport:
|
|
"""Result of bytecode similarity analysis against the scam DB."""
|
|
|
|
sha256_hash: str = ""
|
|
exact_match: bool = False
|
|
similarity_score: int = 0 # 0-100
|
|
matched_scam_type: str | None = None
|
|
matched_addresses: list[str] = field(default_factory=list)
|
|
risk_label: str = "LOW" # LOW / MEDIUM / HIGH / CRITICAL
|
|
warnings: list[str] = field(default_factory=list)
|
|
|
|
|
|
# ── Analyzer ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class BytecodeSimilarityAnalyzer:
|
|
"""Compare token bytecode against a local database of known scam contracts.
|
|
|
|
Usage::
|
|
|
|
analyzer = BytecodeSimilarityAnalyzer()
|
|
report = await analyzer.analyze(bytecode_hex, chain="ethereum")
|
|
analyzer.add_to_db(hash, {"type": "rug", "address": "0x...", ...})
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._db: dict[str, dict[str, Any]] = {}
|
|
self._loaded = False
|
|
|
|
# ── Database Management ──────────────────────────────────────────
|
|
|
|
def _load_db(self) -> dict[str, dict[str, Any]]:
|
|
"""Load scam bytecode DB from disk (lazy)."""
|
|
if self._loaded:
|
|
return self._db
|
|
self._loaded = True
|
|
|
|
if not os.path.exists(DB_PATH):
|
|
logger.info(f"No scam bytecode DB at {DB_PATH}, starting fresh")
|
|
self._db = {}
|
|
return self._db
|
|
|
|
try:
|
|
with open(DB_PATH) as f:
|
|
self._db = json.load(f)
|
|
logger.info(f"Loaded {len(self._db)} scam bytecode entries from {DB_PATH}")
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.warning(f"Failed to load scam bytecode DB: {e}")
|
|
self._db = {}
|
|
return self._db
|
|
|
|
def _save_db(self):
|
|
"""Persist the in-memory DB to disk."""
|
|
try:
|
|
with open(DB_PATH, "w") as f:
|
|
json.dump(self._db, f, indent=2)
|
|
logger.info(f"Saved {len(self._db)} entries to {DB_PATH}")
|
|
except OSError as e:
|
|
logger.error(f"Failed to save scam bytecode DB: {e}")
|
|
|
|
def add_to_db(self, bytecode_hash: str, metadata: dict[str, Any]):
|
|
"""Add (or update) a bytecode hash entry in the scam DB.
|
|
|
|
Args:
|
|
bytecode_hash: SHA-256 hex digest returned by :func:`hash_bytecode`.
|
|
metadata: Dict with at least ``scam_type`` and ``address`` keys.
|
|
May also include ``chain``, ``name``, ``notes``, etc.
|
|
"""
|
|
self._load_db()
|
|
existing = self._db.get(bytecode_hash)
|
|
if existing:
|
|
# Merge: append new address if not already tracked
|
|
existing.setdefault("addresses", [])
|
|
addr = metadata.get("address", "")
|
|
if addr and addr not in existing["addresses"]:
|
|
existing["addresses"].append(addr)
|
|
# Update scam_type if more specific
|
|
new_type = metadata.get("scam_type", "")
|
|
if new_type and not existing.get("scam_type"):
|
|
existing["scam_type"] = new_type
|
|
existing["count"] = existing.get("count", 1) + 1
|
|
logger.info(f"Updated existing DB entry for hash {bytecode_hash[:12]}...")
|
|
else:
|
|
entry: dict[str, Any] = {
|
|
"scam_type": metadata.get("scam_type", "unknown"),
|
|
"addresses": [metadata.get("address", "unknown")],
|
|
"chain": metadata.get("chain", ""),
|
|
"name": metadata.get("name", ""),
|
|
"count": 1,
|
|
"notes": metadata.get("notes", ""),
|
|
}
|
|
self._db[bytecode_hash] = entry
|
|
logger.info(f"Added new scam bytecode entry: {bytecode_hash[:12]}... → {entry['scam_type']}")
|
|
|
|
self._save_db()
|
|
|
|
# ── Bytecode Fetching ────────────────────────────────────────────
|
|
|
|
async def _fetch_bytecode(self, address: str, chain: str) -> str | None:
|
|
"""Fetch raw bytecode for *address* on *chain*.
|
|
|
|
Returns hex string (may include ``0x`` prefix) or ``None``.
|
|
"""
|
|
chain_lower = chain.lower()
|
|
|
|
if is_solana(chain_lower):
|
|
return await self._fetch_solana_bytecode(address)
|
|
|
|
# EVM path
|
|
chain_id = CHAIN_TO_ID.get(chain_lower)
|
|
if not chain_id:
|
|
logger.warning(f"Unknown chain for bytecode fetch: {chain}")
|
|
return None
|
|
|
|
return await self._fetch_evm_bytecode(address, chain_id, chain_lower)
|
|
|
|
async def _fetch_evm_bytecode(self, address: str, chain_id: int, chain_name: str) -> str | None:
|
|
"""Fetch EVM bytecode via consensus RPC, with fallback."""
|
|
# Primary: cached consensus RPC
|
|
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"Fetched EVM bytecode for {address} on {chain_name} (confidence={result.confidence}%)")
|
|
return str(code_hex)
|
|
logger.debug(f"eth_getCode returned empty for {address} on {chain_name}")
|
|
except Exception as e:
|
|
logger.debug(f"Consensus RPC bytecode fetch failed: {e}")
|
|
|
|
# Fallback: direct public RPC
|
|
try:
|
|
fallback_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",
|
|
43114: "https://avalanche-c-chain-rpc.publicnode.com",
|
|
250: "https://fantom-rpc.publicnode.com",
|
|
}
|
|
url = fallback_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
|
|
|
|
async def _fetch_solana_bytecode(self, address: str) -> str | None:
|
|
"""Fetch Solana program / account data as a hashable string."""
|
|
# Try cached consensus RPC for getAccountInfo
|
|
try:
|
|
from app.caching_shield.rpc_cache import get_rpc_cache
|
|
|
|
cache = get_rpc_cache()
|
|
result = await cache.query_with_cache(
|
|
method="getAccountInfo",
|
|
params=[address, {"encoding": "base64"}],
|
|
chain="solana",
|
|
)
|
|
if result and result.value:
|
|
raw = result.value
|
|
# result.value is the JSON-RPC result dict
|
|
if isinstance(raw, dict):
|
|
data_list = raw.get("data")
|
|
if isinstance(data_list, list) and len(data_list) >= 1:
|
|
return str(data_list[0]) # base64 encoded
|
|
return str(raw)
|
|
return str(raw)
|
|
except Exception as e:
|
|
logger.debug(f"Consensus RPC Solana fetch failed: {e}")
|
|
|
|
# Fallback: direct Solana RPC
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.post(
|
|
SOLANA_RPC,
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "getAccountInfo",
|
|
"params": [address, {"encoding": "base64"}],
|
|
},
|
|
)
|
|
if resp.status_code == 200:
|
|
body = resp.json()
|
|
result_val = body.get("result")
|
|
if result_val and isinstance(result_val, dict):
|
|
data_list = result_val.get("data")
|
|
if isinstance(data_list, list) and len(data_list) >= 1:
|
|
return str(data_list[0])
|
|
except Exception as e:
|
|
logger.debug(f"Solana fallback RPC failed: {e}")
|
|
|
|
# Last resort: try free solscan client for token info
|
|
try:
|
|
from app.free_solscan_client import FreeSolscanClient
|
|
|
|
info = FreeSolscanClient.account_info(address)
|
|
if info:
|
|
return str(info)
|
|
except Exception as e:
|
|
logger.debug(f"FreeSolscan fetch failed: {e}")
|
|
|
|
return None
|
|
|
|
# ── Similarity Scoring ───────────────────────────────────────────
|
|
|
|
def _score_similarity(self, hash_a: str, hash_b: str) -> int:
|
|
"""Compute a similarity score (0-100) between two bytecode hashes.
|
|
|
|
Since we use SHA-256, exact hashes get 100; anything else is
|
|
compared via nibble-level similarity as a fallback heuristic.
|
|
"""
|
|
if hash_a == hash_b:
|
|
return 100
|
|
if not hash_a or not hash_b:
|
|
return 0
|
|
|
|
# Nibble-level comparison: count matching hex characters
|
|
min_len = min(len(hash_a), len(hash_b))
|
|
matches = sum(1 for i in range(min_len) if hash_a[i] == hash_b[i])
|
|
# Weight: 60% position-aware match + 40% character-set overlap
|
|
position_score = (matches / max(len(hash_a), len(hash_b))) * 60
|
|
|
|
set_a = set(hash_a)
|
|
set_b = set(hash_b)
|
|
if set_a and set_b:
|
|
overlap = len(set_a & set_b) / len(set_a | set_b)
|
|
set_score = overlap * 40
|
|
else:
|
|
set_score = 0
|
|
|
|
return min(99, int(position_score + set_score))
|
|
|
|
# ── Main Analysis ────────────────────────────────────────────────
|
|
|
|
async def analyze(self, bytecode_hex: str, chain: str) -> BytecodeSimilarityReport:
|
|
"""Hash *bytecode_hex* and compare against the scam DB.
|
|
|
|
The *bytecode_hex* may be a hex string of raw bytecode, or an
|
|
*address* (which will be resolved on-chain). If *chain* is
|
|
provided and *bytecode_hex* looks like an address (0x-prefixed
|
|
42-char string or base58), it is treated as an address to fetch.
|
|
|
|
Returns:
|
|
BytecodeSimilarityReport with similarity score and risk label.
|
|
"""
|
|
# Normalise input
|
|
chain_lower = chain.lower()
|
|
raw_hex: str | None = None
|
|
|
|
# Detect if input is an address rather than raw bytecode
|
|
if self._looks_like_address(bytecode_hex):
|
|
logger.info(f"Input looks like an address, fetching bytecode for {bytecode_hex}")
|
|
raw_hex = await self._fetch_bytecode(bytecode_hex, chain_lower)
|
|
if not raw_hex:
|
|
return BytecodeSimilarityReport(
|
|
sha256_hash="",
|
|
warnings=[f"Could not fetch bytecode for address {bytecode_hex} on {chain}"],
|
|
)
|
|
else:
|
|
raw_hex = bytecode_hex
|
|
|
|
# Hash the cleaned bytecode
|
|
bytecode_hash = hash_bytecode(raw_hex)
|
|
|
|
report = BytecodeSimilarityReport(
|
|
sha256_hash=bytecode_hash,
|
|
)
|
|
|
|
# Load DB
|
|
db = self._load_db()
|
|
if not db:
|
|
report.warnings.append("Scam bytecode DB is empty - no comparisons possible")
|
|
return report
|
|
|
|
# Check for exact match
|
|
if bytecode_hash in db:
|
|
entry = db[bytecode_hash]
|
|
report.exact_match = True
|
|
report.similarity_score = 100
|
|
report.matched_scam_type = entry.get("scam_type", "unknown")
|
|
report.matched_addresses = entry.get("addresses", [])
|
|
report.risk_label = "CRITICAL"
|
|
report.warnings.append(
|
|
f"Exact bytecode hash match! Recycled {report.matched_scam_type} scam "
|
|
f"found at {', '.join(report.matched_addresses[:3])}"
|
|
)
|
|
logger.warning(f"Exact scam match: hash {bytecode_hash[:12]}... type={report.matched_scam_type}")
|
|
return report
|
|
|
|
# Scan for partial matches via nibble similarity
|
|
best_score = 0
|
|
best_entry: dict[str, Any] | None = None
|
|
|
|
for db_hash, entry_data in db.items():
|
|
score = self._score_similarity(bytecode_hash, db_hash)
|
|
if score > best_score:
|
|
best_score = score
|
|
best_entry = entry_data
|
|
|
|
if best_entry and best_score > 0:
|
|
report.similarity_score = min(best_score, 99) # never 100 - that's exact
|
|
report.matched_scam_type = best_entry.get("scam_type", "unknown")
|
|
report.matched_addresses = best_entry.get("addresses", [])
|
|
|
|
if best_score >= 80:
|
|
report.risk_label = "HIGH"
|
|
report.warnings.append(
|
|
f"Bytecode is {best_score}% similar to known {report.matched_scam_type} "
|
|
f"scam at {report.matched_addresses[0] if report.matched_addresses else 'unknown'}"
|
|
)
|
|
elif best_score >= 50:
|
|
report.risk_label = "MEDIUM"
|
|
report.warnings.append(f"Bytecode is {best_score}% similar to known scam contract")
|
|
else:
|
|
report.risk_label = "LOW"
|
|
else:
|
|
report.similarity_score = 0
|
|
report.risk_label = "LOW"
|
|
report.warnings.append("No similar bytecode found in scam DB")
|
|
|
|
return report
|
|
|
|
# ── Internal Helpers ─────────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def _looks_like_address(input_str: str) -> bool:
|
|
"""Heuristic: is the input an address rather than raw bytecode?"""
|
|
s = input_str.strip()
|
|
# EVM address: 0x + 40 hex chars
|
|
if re.match(r"^0x[0-9a-fA-F]{40}$", s):
|
|
return True
|
|
# Solana address: base58, 32-44 chars
|
|
return bool(re.match(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$", s))
|
|
|
|
# ── Resource Cleanup ─────────────────────────────────────────────
|
|
|
|
async def close(self):
|
|
"""No-op for interface compatibility with other SENTINEL scanners."""
|
|
pass
|