| """ |
| SENTINEL β Proxy Detection + Implementation Analysis |
| ====================================================== |
| Resolves proxy contracts to their actual implementations and assesses |
| the risk of upgradeable proxy patterns: |
| |
| - For EVM: read EIP-1967 implementation slot, also check old-style proxies |
| - For Solana: check if program is upgradeable (check upgrade authority) |
| - Compare implementation bytecode hash against known-rug contract database |
| - Detect if owner can change implementation at any time (no timelock) |
| |
| Uses direct API calls: Etherscan (contract source, bytecode), RPC (eth_call), |
| Helius (Solana program info), DexScreener. |
| """ |
|
|
| import hashlib |
| import logging |
| import os |
| from dataclasses import dataclass, field |
| from typing import Any |
|
|
| import httpx |
|
|
| from app.chain_client import ChainClient |
| from app.chain_registry import is_evm, is_solana |
| from app.scanners.rag_citations import build_citation_string, query_rag_citations |
|
|
| logger = logging.getLogger("proxy_detector") |
|
|
|
|
| |
|
|
| |
| EIP1967_IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" |
| EIP1967_ADMIN_SLOT = "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103" |
| EIP1967_BEACON_SLOT = "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50" |
|
|
| |
| OLD_PROXY_IMPLEMENTATION_SLOT = "0x5f3b5cf7" + "0" * 56 |
| |
| OLD_PROXY_SLOTS = [ |
| "0x5f3b5cf705d9414d8c169be3f1e4e6b9178f7e1a3b04d5e8a1c2b3d4e5f6a7b8", |
| ] |
|
|
| |
| KNOWN_SAFE_IMPLEMENTATIONS: dict[str, str] = { |
| |
| "oze_erc20": "safe", |
| |
| "oze_erc721": "safe", |
| } |
|
|
| |
| KNOWN_RUG_PATTERNS = [ |
| "0x2e1a7d4d", |
| "0x3ccfd60b", |
| "0x51cff8d9", |
| "0xdb006a75", |
| "0x4b2e2f5b", |
| "0xf3fef3a3", |
| "0x5a3f0b2c", |
| "0x2f2ff15d", |
| ] |
|
|
| |
| TIMELOCK_DELAY_SELECTOR = "0xd2baa7d2" |
| TIMELOCK_GRACE_SELECTOR = "0x048ef6db" |
| OWNER_SELECTOR = "0x8da5cb5b" |
| PENDING_OWNER_SELECTOR = "0xe302bc02" |
|
|
|
|
| |
|
|
|
|
| @dataclass |
| class ProxyReport: |
| token_address: str |
| chain: str |
| is_proxy: bool = False |
| proxy_type: str = "" |
| implementation_address: str = "" |
| admin_address: str = "" |
| can_upgrade: bool = False |
| upgrade_timelock: int = 0 |
| is_known_rug_impl: bool = False |
| impl_bytecode_hash: str = "" |
| impl_risk_patterns: list[str] = field(default_factory=list) |
| owner_address: str = "" |
| has_timelock: bool = False |
| risk_score: int = 0 |
| risk_level: str = "LOW" |
| warnings: list[str] = field(default_factory=list) |
| citations: list[dict[str, Any]] = field(default_factory=list) |
|
|
|
|
| |
|
|
|
|
| class ProxyDetector: |
| """Resolves proxy contracts and assesses upgrade risk. |
| |
| For EVM: reads EIP-1967 storage slots and old-style proxy slots to find |
| implementations. For Solana: checks program upgrade authority. |
| Analyzes whether the owner can change implementation without timelock |
| and whether the implementation matches known rug patterns. |
| """ |
|
|
| def __init__(self): |
| self._http = httpx.AsyncClient(timeout=15.0) |
| self._chain = ChainClient() |
| self._helius_key = os.getenv("HELIUS_API_KEY", "") |
| self._etherscan_key = os.getenv("ETHERSCAN_API_KEY", "") |
|
|
| |
|
|
| async def _eth_call(self, to: str, data: str, chain: str) -> str | None: |
| """Make an eth_call to read contract storage.""" |
| from app.chain_registry import CHAINS |
|
|
| chain_cfg = CHAINS.get(chain) |
| if not chain_cfg or not chain_cfg.rpc_endpoints: |
| return None |
| rpc_url = chain_cfg.rpc_endpoints[0] |
| try: |
| resp = await self._http.post( |
| rpc_url, |
| json={ |
| "jsonrpc": "2.0", |
| "id": 1, |
| "method": "eth_call", |
| "params": [{"to": to, "data": data}, "latest"], |
| }, |
| ) |
| if resp.status_code == 200: |
| body = resp.json() |
| result = body.get("result") |
| if result and result != "0x" and result != "0x0": |
| return result |
| except Exception as e: |
| logger.warning(f"eth_call failed for {to} data={data[:10]}: {e}") |
| return None |
|
|
| async def _eth_getCode(self, address: str, chain: str) -> str | None: |
| """Get contract bytecode via eth_getCode.""" |
| from app.chain_registry import CHAINS |
|
|
| chain_cfg = CHAINS.get(chain) |
| if not chain_cfg or not chain_cfg.rpc_endpoints: |
| return None |
| rpc_url = chain_cfg.rpc_endpoints[0] |
| try: |
| resp = await self._http.post( |
| rpc_url, |
| json={ |
| "jsonrpc": "2.0", |
| "id": 1, |
| "method": "eth_getCode", |
| "params": [address, "latest"], |
| }, |
| ) |
| if resp.status_code == 200: |
| body = resp.json() |
| return body.get("result") |
| except Exception as e: |
| logger.warning(f"eth_getCode failed for {address}: {e}") |
| return None |
|
|
| async def _fetch_contract_source(self, address: str, chain: str) -> dict | None: |
| """Fetch contract source and ABI from Etherscan-style explorer.""" |
| from app.chain_registry import CHAINS |
|
|
| chain_cfg = CHAINS.get(chain) |
| if not chain_cfg or not chain_cfg.explorer_api_url: |
| return None |
| key = chain_cfg.get_explorer_api_key() or self._etherscan_key |
| if not key: |
| return None |
| try: |
| url = f"{chain_cfg.explorer_api_url}?module=contract&action=getsourcecode&address={address}&apikey={key}" |
| resp = await self._http.get(url) |
| if resp.status_code == 200: |
| body = resp.json() |
| result = body.get("result", []) |
| if result and len(result) > 0: |
| return result[0] |
| except Exception as e: |
| logger.warning(f"Etherscan source fetch failed for {address}: {e}") |
| return None |
|
|
| async def _fetch_solana_program_data(self, program_id: str) -> dict | None: |
| """Fetch Solana program account data to check upgrade authority.""" |
| result = await self._chain.rpc_call("getAccountInfo", [program_id, {"encoding": "base64"}]) |
| if result and "result" in result: |
| info = result["result"].get("value") |
| return info |
| return None |
|
|
| async def _fetch_solana_upgrade_authority(self, program_id: str) -> str | None: |
| """Fetch the upgrade authority for a Solana BPF program. |
| |
| Uses getProgramData Helius/RPC extension or falls back to |
| account parsing. |
| """ |
| if self._helius_key: |
| try: |
| async with httpx.AsyncClient(timeout=10.0) as client: |
| r = await client.post( |
| f"https://api.helius.xyz/v0/programs/{program_id}?api-key={self._helius_key}", |
| json={}, |
| ) |
| if r.status_code == 200: |
| data = r.json() |
| return data.get("upgradeAuthority") |
| except Exception as e: |
| logger.warning(f"Helius program data failed for {program_id}: {e}") |
|
|
| |
| result = await self._chain.rpc_call("getProgramAccounts", [program_id, {"encoding": "jsonParsed"}]) |
| if result and "result" in result: |
| accounts = result["result"] |
| for acc in accounts: |
| parsed = acc.get("account", {}).get("data", {}).get("parsed", {}) |
| if parsed.get("type") == "programData": |
| info = parsed.get("info", {}) |
| return info.get("upgradeAuthority") |
| return None |
|
|
| |
|
|
| async def _check_evm_proxy(self, address: str, chain: str) -> tuple[bool, str, str, str]: |
| """Check if an EVM contract is a proxy and resolve implementation. |
| |
| Returns: (is_proxy, proxy_type, implementation_address, admin_address) |
| """ |
| |
| impl = await self._eth_call(address, EIP1967_IMPLEMENTATION_SLOT, chain) |
| if impl and impl != "0x" and len(impl) > 2: |
| impl_addr = "0x" + impl[-40:] |
| if int(impl_addr, 16) != 0: |
| admin = await self._eth_call(address, EIP1967_ADMIN_SLOT, chain) |
| admin_addr = ("0x" + admin[-40:]) if admin and len(admin) > 42 else "" |
| return True, "eip1967", impl_addr, admin_addr |
|
|
| |
| beacon = await self._eth_call(address, EIP1967_BEACON_SLOT, chain) |
| if beacon and beacon != "0x" and len(beacon) > 2: |
| beacon_addr = "0x" + beacon[-40:] |
| if int(beacon_addr, 16) != 0: |
| |
| beacon_impl = await self._eth_call(beacon_addr, "0x5c60da1b", chain) |
| if beacon_impl and len(beacon_impl) > 2: |
| impl_addr = "0x" + beacon_impl[-40:] |
| return True, "beacon", impl_addr, beacon_addr |
|
|
| |
| old_impl = await self._eth_call( |
| address, "0x5f3b5cf700000000000000000000000000000000000000000000000000000000", chain |
| ) |
| if old_impl and old_impl != "0x" and len(old_impl) > 2: |
| impl_addr = "0x" + old_impl[-40:] |
| if int(impl_addr, 16) != 0: |
| return True, "old_style", impl_addr, "" |
|
|
| |
| direct_impl = await self._eth_call(address, "0x5c60da1b", chain) |
| if direct_impl and direct_impl != "0x" and len(direct_impl) > 2: |
| impl_addr = "0x" + direct_impl[-40:] |
| if int(impl_addr, 16) != 0: |
| return True, "function_call", impl_addr, "" |
|
|
| return False, "", "", "" |
|
|
| async def _check_can_upgrade(self, address: str, admin_addr: str, chain: str) -> tuple[bool, str, int]: |
| """Check if the proxy admin can upgrade implementation. |
| |
| Returns: (can_upgrade, owner_address, timelock_seconds) |
| """ |
| owner_addr = "" |
| timelock_seconds = 0 |
|
|
| |
| check_addr = admin_addr if admin_addr else address |
|
|
| |
| owner_result = await self._eth_call(check_addr, OWNER_SELECTOR, chain) |
| if owner_result and len(owner_result) > 2: |
| owner_addr = "0x" + owner_result[-40:] |
|
|
| |
| source = await self._fetch_contract_source(check_addr, chain) |
| if source: |
| source_code = source.get("SourceCode", "") |
| if source_code and "timelock" in source_code.lower(): |
| |
| delay_result = await self._eth_call(check_addr, TIMELOCK_DELAY_SELECTOR, chain) |
| if delay_result and len(delay_result) > 2: |
| try: |
| timelock_seconds = int(delay_result, 16) |
| except ValueError: |
| timelock_seconds = 0 |
|
|
| |
| can_upgrade = bool(owner_addr and int(owner_addr, 16) != 0) |
|
|
| return can_upgrade, owner_addr, timelock_seconds |
|
|
| def _check_impl_for_rug_patterns(self, impl_address: str, bytecode: str) -> tuple[bool, list[str], str]: |
| """Check implementation bytecode for known rug contract patterns. |
| |
| Returns: (is_known_rug, risk_patterns, bytecode_hash) |
| """ |
| if not bytecode or bytecode == "0x": |
| return False, [], "" |
|
|
| bytecode_hash = hashlib.sha256(bytecode.encode()).hexdigest()[:16] |
| found_patterns = [] |
|
|
| for pattern in KNOWN_RUG_PATTERNS: |
| if pattern[2:] in bytecode.lower(): |
| found_patterns.append(pattern) |
|
|
| |
| is_rug = len(found_patterns) >= 3 |
|
|
| return is_rug, found_patterns, bytecode_hash |
|
|
| |
|
|
| def _calculate_risk(self, report: ProxyReport) -> tuple[int, str, list[str]]: |
| """Calculate risk score from proxy analysis.""" |
| score = 0 |
| warnings = [] |
|
|
| if not report.is_proxy: |
| return 0, "LOW", [] |
|
|
| |
| score += 5 |
|
|
| |
| if report.can_upgrade and report.upgrade_timelock == 0: |
| score += 35 |
| warnings.append( |
| "CRITICAL: Proxy is upgradeable with NO timelock β owner can change implementation instantly" |
| ) |
| elif report.can_upgrade and report.upgrade_timelock < 86400: |
| score += 20 |
| warnings.append( |
| f"HIGH: Proxy timelock only {report.upgrade_timelock // 3600}h β insufficient reaction time" |
| ) |
| elif report.can_upgrade: |
| score += 10 |
| hours = report.upgrade_timelock / 3600 |
| warnings.append(f"MEDIUM: Proxy has {hours:.0f}h timelock on upgrades") |
|
|
| |
| if report.is_known_rug_impl: |
| score += 40 |
| warnings.append("CRITICAL: Implementation matches known rug contract pattern") |
|
|
| |
| if report.impl_risk_patterns: |
| count = len(report.impl_risk_patterns) |
| if count >= 3: |
| score += 25 |
| warnings.append(f"HIGH: {count} suspicious functions in implementation (drain/withdraw/setOwner)") |
| elif count >= 1: |
| score += 10 |
| warnings.append(f"MEDIUM: {count} suspicious function(s) in implementation") |
|
|
| |
| if report.proxy_type == "old_style": |
| score += 10 |
| warnings.append("MEDIUM: Old-style proxy β lacks EIP-1967 security features") |
|
|
| |
| if report.is_proxy and not report.admin_address and not report.owner_address: |
| score += 10 |
| warnings.append("MEDIUM: Cannot determine proxy admin β unclear upgrade authority") |
|
|
| |
| if report.proxy_type == "solana_upgradeable": |
| score += 25 |
| warnings.append("HIGH: Solana program is upgradeable β authority can modify code") |
|
|
| score = min(100, score) |
|
|
| if score >= 70: |
| level = "CRITICAL" |
| elif score >= 40: |
| level = "HIGH" |
| elif score >= 20: |
| level = "MEDIUM" |
| else: |
| level = "LOW" |
|
|
| return score, level, warnings |
|
|
| |
|
|
| async def analyze(self, token_address: str, chain: str) -> ProxyReport: |
| """Full proxy detection and implementation analysis for a token. |
| |
| Steps: |
| 1. For EVM: check proxy slots and resolve implementation |
| 2. For Solana: check program upgrade authority |
| 3. Determine if upgradeable (no timelock) |
| 4. Check implementation for known rug patterns |
| 5. Calculate risk score |
| """ |
| report = ProxyReport(token_address=token_address, chain=chain) |
|
|
| if is_evm(chain): |
| |
| is_proxy, proxy_type, impl_addr, admin_addr = await self._check_evm_proxy(token_address, chain) |
| report.is_proxy = is_proxy |
| report.proxy_type = proxy_type |
| report.implementation_address = impl_addr |
| report.admin_address = admin_addr |
|
|
| if is_proxy and impl_addr: |
| |
| can_upgrade, owner_addr, timelock_seconds = await self._check_can_upgrade( |
| token_address, admin_addr, chain |
| ) |
| report.can_upgrade = can_upgrade |
| report.owner_address = owner_addr |
| report.upgrade_timelock = timelock_seconds |
| report.has_timelock = timelock_seconds > 0 |
|
|
| |
| bytecode = await self._eth_getCode(impl_addr, chain) |
| if bytecode: |
| is_rug, patterns, code_hash = self._check_impl_for_rug_patterns(impl_addr, bytecode) |
| report.is_known_rug_impl = is_rug |
| report.impl_risk_patterns = patterns |
| report.impl_bytecode_hash = code_hash |
|
|
| |
| impl_source = await self._fetch_contract_source(impl_addr, chain) |
| if impl_source: |
| source = impl_source.get("SourceCode", "") |
| if source and "timelock" in source.lower(): |
| report.has_timelock = True |
| if report.upgrade_timelock == 0: |
| |
| report.upgrade_timelock = 86400 |
|
|
| elif is_solana(chain): |
| |
| |
| program_data = await self._fetch_solana_program_data(token_address) |
| if program_data: |
| data = program_data.get("data", []) |
| if isinstance(data, list) and len(data) > 0: |
| parsed = data[0].get("parsed", {}) if isinstance(data[0], dict) else {} |
| program_type = parsed.get("type", "") |
| if program_type == "account": |
| owner_program = program_data.get("owner", "") |
| |
| if owner_program and owner_program != "11111111111111111111111111111111": |
| upgrade_auth = await self._fetch_solana_upgrade_authority(owner_program) |
| if upgrade_auth: |
| report.is_proxy = True |
| report.proxy_type = "solana_upgradeable" |
| report.can_upgrade = True |
| report.implementation_address = owner_program |
| report.admin_address = upgrade_auth |
| report.owner_address = upgrade_auth |
| else: |
| |
| report.is_proxy = False |
| report.can_upgrade = False |
|
|
| |
| report.risk_score, report.risk_level, report.warnings = self._calculate_risk(report) |
|
|
| |
| try: |
| rag_cits = await query_rag_citations( |
| topic=f"proxy contract upgrade risk {chain}", |
| chain=chain, |
| address=token_address, |
| scanner_type="proxy_detect", |
| ) |
| report.citations = rag_cits |
| |
| for i, w in enumerate(report.warnings): |
| if rag_cits: |
| report.warnings[i] = build_citation_string(rag_cits, w) |
| except Exception: |
| pass |
|
|
| return report |
|
|