""" 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: # # 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