| """ |
| 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") |
|
|
| |
|
|
| _THIS_DIR = os.path.dirname(os.path.abspath(__file__)) |
| DB_PATH = os.path.join(_THIS_DIR, "scam_bytecode_db.json") |
|
|
| |
| 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 = "https://api.mainnet-beta.solana.com" |
|
|
| |
| METADATA_MARKERS = ["a1", "a2", "65766d", "627a6574"] |
|
|
|
|
| |
|
|
|
|
| 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() |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| for marker in METADATA_MARKERS: |
| idx = clean.rfind(marker) |
| if idx > len(clean) // 2: |
| clean = clean[:idx] |
|
|
| return hashlib.sha256(clean.encode()).hexdigest() |
|
|
|
|
| |
|
|
|
|
| @dataclass |
| class BytecodeSimilarityReport: |
| """Result of bytecode similarity analysis against the scam DB.""" |
|
|
| sha256_hash: str = "" |
| exact_match: bool = False |
| similarity_score: int = 0 |
| matched_scam_type: str | None = None |
| matched_addresses: list[str] = field(default_factory=list) |
| risk_label: str = "LOW" |
| warnings: list[str] = field(default_factory=list) |
|
|
|
|
| |
|
|
|
|
| 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 |
|
|
| |
|
|
| 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: |
| |
| existing.setdefault("addresses", []) |
| addr = metadata.get("address", "") |
| if addr and addr not in existing["addresses"]: |
| existing["addresses"].append(addr) |
| |
| 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() |
|
|
| |
|
|
| 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) |
|
|
| |
| 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.""" |
| |
| 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}") |
|
|
| |
| 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: |
| 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 |
| |
| if isinstance(raw, dict): |
| data_list = raw.get("data") |
| if isinstance(data_list, list) and len(data_list) >= 1: |
| return str(data_list[0]) |
| return str(raw) |
| return str(raw) |
| except Exception as e: |
| logger.debug(f"Consensus RPC Solana fetch failed: {e}") |
|
|
| |
| 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}") |
|
|
| |
| 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 |
|
|
| |
|
|
| 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 |
|
|
| |
| 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]) |
| |
| 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)) |
|
|
| |
|
|
| 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. |
| """ |
| |
| chain_lower = chain.lower() |
| raw_hex: str | None = None |
|
|
| |
| 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 |
|
|
| |
| bytecode_hash = hash_bytecode(raw_hex) |
|
|
| report = BytecodeSimilarityReport( |
| sha256_hash=bytecode_hash, |
| ) |
|
|
| |
| db = self._load_db() |
| if not db: |
| report.warnings.append("Scam bytecode DB is empty β no comparisons possible") |
| return report |
|
|
| |
| 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 |
|
|
| |
| 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) |
| 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 |
|
|
| |
|
|
| @staticmethod |
| def _looks_like_address(input_str: str) -> bool: |
| """Heuristic: is the input an address rather than raw bytecode?""" |
| s = input_str.strip() |
| |
| if re.match(r"^0x[0-9a-fA-F]{40}$", s): |
| return True |
| |
| return bool(re.match(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$", s)) |
|
|
| |
|
|
| async def close(self): |
| """No-op for interface compatibility with other SENTINEL scanners.""" |
| pass |
|
|