rmi-backend/app/contract_upgrade_monitor.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

758 lines
29 KiB
Python

"""
Contract Upgrade Monitor
========================
Monitor proxy contract upgrades in real-time - detects malicious implementation
swaps, hidden timelock changes, and privilege escalation through upgrade patterns.
TOOL : contract_upgrade_monitor
TIER : security
PRICE : $0.05 (50000 atoms)
TRIAL : 2 free checks
Data Sources (all free-tier):
- Etherscan/BscScan/Polygonscan API - contract ABI, source code, tx history
- OpenZeppelin proxy patterns (UUPS, Transparent, Beacon)
- Public RPCs - storage slot reads for implementation address
- EIP-1967, EIP-1822, EIP-1167 storage slot standards
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
from dataclasses import dataclass, field
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# ── Constants ────────────────────────────────────────────────────
# EIP-1967 proxy storage slots
EIP1967_IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"
EIP1967_ADMIN_SLOT = "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103"
EIP1967_BEACON_SLOT = "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50"
# EIP-1822 (UUPS) storage slot
EIP1822_IMPLEMENTATION_SLOT = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7"
BEACON_IMPLEMENTATION_SLOT = "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50"
ETHERSCAN_BASES: dict[str, str] = {
"ethereum": "https://api.etherscan.io/api",
"bsc": "https://api.bscscan.com/api",
"polygon": "https://api.polygonscan.com/api",
"arbitrum": "https://api.arbiscan.io/api",
"optimism": "https://api-optimistic.etherscan.io/api",
"base": "https://api.basescan.org/api",
"avalanche": "https://api.snowtrace.io/api",
}
# Supported proxy patterns
PROXY_PATTERNS = {
"eip1967": "EIP-1967 Transparent/Universal Proxy",
"eip1822": "EIP-1822 UUPS Proxy",
"beacon": "EIP-1967 Beacon Proxy",
"gnosis_safe": "Gnosis Safe Proxy",
"openzeppelin": "OpenZeppelin Proxy (legacy)",
"eip1167": "EIP-1167 Minimal Proxy (clone)",
}
# Dangerous upgrade-related function selectors (4-byte signatures)
DANGEROUS_SELECTORS = {
"0x3659cfe6": "upgradeTo(address)",
"0x4f1ef286": "upgradeToAndCall(address,bytes)",
"0x9623609d": "_upgradeTo(address)",
"0x99a88ec4": "upgrade(address)",
"0x077f224a": "upgradeImplementation(address)",
"0x2e4a7ec4": "setImplementation(address)",
"0x8eaa6ac0": "changeAdmin(address)",
"0xf851a440": "owner()",
"0x8da5cb5b": "owner() [alternative]",
"0x5c60da1b": "implementation()",
"0xbb20d857": "upgradeToAndCall(address,bytes)",
"0x1a8450c0": "setAddress(address)",
"0xcb13bd91": "setBeacon(address)",
"0x9b3b76cc": "upgradeBeaconTo(address,bytes)",
}
# Proxy storage diff threshold - if implementation changes within this window
SUSPICIOUS_UPGRADE_WINDOW = 86400 # 24 hours
# Cache helpers
_cache: dict[str, tuple[float, Any]] = {}
_CACHE_TTL = 300 # 5 minutes
_MAX_STALE_TTL = 1800 # 30 minutes
# ── Data Models ───────────────────────────────────────────────────
@dataclass
class ProxyInfo:
"""Detected proxy pattern and current implementation."""
address: str
chain: str
proxy_type: str | None = None
proxy_type_name: str | None = None
implementation_address: str | None = None
admin_address: str | None = None
beacon_address: str | None = None
is_proxy: bool = False
confidence: float = 0.0 # 0.0 - 1.0
detected_selectors: list[str] = field(default_factory=list)
@dataclass
class UpgradeEvent:
"""Record of a proxy upgrade event."""
block_number: int
transaction_hash: str
timestamp: int
previous_implementation: str | None = None
new_implementation: str | None = None
triggered_by: str | None = None # address that triggered the upgrade
function_selector: str | None = None
@dataclass
class ContractUpgradeReport:
"""Complete upgrade monitoring report for a contract."""
contract_address: str
chain: str
proxy_info: ProxyInfo | None = None
upgrade_history: list[UpgradeEvent] = field(default_factory=list)
recent_suspicious_upgrades: list[UpgradeEvent] = field(default_factory=list)
timelock_status: str | None = None # "active", "bypassed", "none"
risk_score: float = 0.0 # 0-100
risk_factors: list[str] = field(default_factory=list)
admin_privileges: list[str] = field(default_factory=list)
upgrade_count_30d: int = 0
last_upgrade_time: int | None = None
implementation_age_days: int | None = None
summary: str = ""
# ── Address Validation ────────────────────────────────────────────
EVM_ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
def is_valid_evm_address(address: str) -> bool:
"""Validate an EVM address format with optional EIP-55 checksum."""
return bool(EVM_ADDRESS_RE.match(address))
# ── HTTP Client ───────────────────────────────────────────────────
async def _fetch_url(url: str, headers: dict | None = None, timeout: float = 15.0) -> dict | None:
"""Fetch JSON from a URL with caching."""
cache_key = f"fetch:{url}"
now = time.time()
# Check cache
if cache_key in _cache:
cached_time, cached_data = _cache[cache_key]
if now - cached_time < _CACHE_TTL:
return cached_data
if now - cached_time > _MAX_STALE_TTL:
del _cache[cache_key]
else:
# Return stale, refresh in background
asyncio.ensure_future(_background_refresh(url, headers, timeout, cache_key))
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(url, headers=headers or {})
if resp.status_code == 200:
data = resp.json()
_cache[cache_key] = (now, data)
return data
elif resp.status_code == 429:
logger.warning("Rate limited on %s", url[:50])
return _cache.get(cache_key, (0, None))[1]
else:
logger.warning("HTTP %s for %s", resp.status_code, url[:50])
return None
except httpx.TimeoutException:
logger.warning("Timeout fetching %s", url[:50])
return _cache.get(cache_key, (0, None))[1]
except Exception as e:
logger.debug("Fetch error for %s: %s", url[:50], e)
return _cache.get(cache_key, (0, None))[1]
async def _background_refresh(url: str, headers: dict | None, timeout: float, cache_key: str) -> None:
"""Refresh cache in background so stale data serves immediately."""
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(url, headers=headers or {})
if resp.status_code == 200:
_cache[cache_key] = (time.time(), resp.json())
except Exception:
pass
# ── RPC Storage Slot Reader ──────────────────────────────────────
async def read_storage_slot(address: str, slot: str, chain: str, client: httpx.AsyncClient | None = None) -> str | None:
"""Read an EVM storage slot using a public RPC endpoint.
Uses chain-specific public RPCs. Returns the 32-byte hex value.
"""
rpc_urls = {
"ethereum": os.getenv("ETH_RPC", "https://ethereum-rpc.publicnode.com"),
"bsc": os.getenv("BSC_RPC", "https://bsc-rpc.publicnode.com"),
"polygon": os.getenv("POLYGON_RPC", "https://polygon-rpc.publicnode.com"),
"arbitrum": os.getenv("ARBITRUM_RPC", "https://arbitrum-rpc.publicnode.com"),
"optimism": os.getenv("OPTIMISM_RPC", "https://optimism-rpc.publicnode.com"),
"base": os.getenv("BASE_RPC", "https://base-rpc.publicnode.com"),
"avalanche": os.getenv("AVALANCHE_RPC", "https://avalanche-rpc.publicnode.com"),
}
rpc_url = rpc_urls.get(chain)
if not rpc_url:
logger.warning("No RPC URL for chain %s", chain)
return None
payload = {
"jsonrpc": "2.0",
"method": "eth_getStorageAt",
"params": [address, slot, "latest"],
"id": 1,
}
try:
close_client = client is None
c = client or httpx.AsyncClient(timeout=10.0)
if close_client:
await c.__aenter__()
try:
resp = await c.post(
rpc_url,
json=payload,
headers={"Content-Type": "application/json"},
)
if resp.status_code == 200:
result = resp.json()
value = result.get("result", "0x0000000000000000000000000000000000000000000000000000000000000000")
# Extract address from padded 32 bytes (last 20 bytes = 40 hex chars)
if value and len(value) >= 66:
addr = "0x" + value[-40:]
if addr != "0x0000000000000000000000000000000000000000":
return addr
return None
finally:
if close_client:
await c.__aexit__(None, None, None)
except Exception as e:
logger.debug("RPC read failed for %s slot %s: %s", address[:10], slot[:10], e)
return None
# ── Proxy Detection ──────────────────────────────────────────────
async def detect_proxy(address: str, chain: str, client: httpx.AsyncClient | None = None) -> ProxyInfo:
"""Detect if an address is a proxy contract and identify its type.
Checks multiple proxy standards in order:
1. EIP-1967 Transparent/Universal Proxy
2. EIP-1822 UUPS Proxy
3. EIP-1967 Beacon Proxy
4. Gnosis Safe (check storage slot)
5. Function selector sniffing (if source code available)
"""
info = ProxyInfo(address=address, chain=chain)
close_client = client is None
c = client or httpx.AsyncClient(timeout=15.0)
if close_client:
await c.__aenter__()
try:
# 1. Check EIP-1967 implementation slot
impl = await read_storage_slot(address, EIP1967_IMPLEMENTATION_SLOT, chain, c)
if impl and is_valid_evm_address(impl):
info.implementation_address = impl
info.is_proxy = True
info.confidence = 0.9
# Check admin slot
admin = await read_storage_slot(address, EIP1967_ADMIN_SLOT, chain, c)
if admin and is_valid_evm_address(admin):
info.admin_address = admin
# Check beacon slot
beacon = await read_storage_slot(address, EIP1967_BEACON_SLOT, chain, c)
if beacon and is_valid_evm_address(beacon):
info.beacon_address = beacon
info.proxy_type = "beacon"
info.proxy_type_name = PROXY_PATTERNS["beacon"]
else:
info.proxy_type = "eip1967"
info.proxy_type_name = PROXY_PATTERNS["eip1967"]
return info
# 2. Check EIP-1822 UUPS slot
impl = await read_storage_slot(address, EIP1822_IMPLEMENTATION_SLOT, chain, c)
if impl and is_valid_evm_address(impl):
info.implementation_address = impl
info.is_proxy = True
info.proxy_type = "eip1822"
info.proxy_type_name = PROXY_PATTERNS["eip1822"]
info.confidence = 0.85
return info
# 3. Check for Gnosis Safe proxy pattern
# Gnosis Safe stores master copy at slot 0
safe_slot = await read_storage_slot(
address, "0x0000000000000000000000000000000000000000000000000000000000000000", chain, c
)
if safe_slot and is_valid_evm_address(safe_slot) and safe_slot != address:
# Additional check: Gnosis Safe has specific bytecode pattern
info.is_proxy = True
info.implementation_address = safe_slot
info.proxy_type = "gnosis_safe"
info.proxy_type_name = PROXY_PATTERNS["gnosis_safe"]
info.confidence = 0.6
# 4. Try source code API for function selector detection
source_info = await _fetch_source_code(address, chain)
if source_info:
detected = _detect_proxy_selectors(source_info.get("ABI", ""))
if detected:
info.detected_selectors = detected
if not info.is_proxy:
info.is_proxy = True
info.proxy_type = "openzeppelin"
info.proxy_type_name = PROXY_PATTERNS["openzeppelin"]
info.confidence = 0.5
return info
finally:
if close_client:
await c.__aexit__(None, None, None)
def _detect_proxy_selectors(abi_json: str) -> list[str]:
"""Detect proxy-related function selectors from contract ABI JSON string."""
detected = []
try:
abi = json.loads(abi_json) if isinstance(abi_json, str) else abi_json
if not isinstance(abi, list):
return detected
for item in abi:
if isinstance(item, dict) and item.get("type") == "function":
name = item.get("name", "")
sig = f"{name}({','.join(i.get('type', '') for i in item.get('inputs', []))})"
# Hash the signature to get 4-byte selector
import hashlib
selector = "0x" + hashlib.keccak_256(sig.encode()).hexdigest()[:8]
if selector in DANGEROUS_SELECTORS:
detected.append(selector)
except (json.JSONDecodeError, Exception):
pass
return detected
# ── Source Code Fetcher ──────────────────────────────────────────
async def _fetch_source_code(address: str, chain: str) -> dict | None:
"""Fetch contract source code and ABI from block explorer."""
base_url = ETHERSCAN_BASES.get(chain)
if not base_url:
return None
# Use free-tier API keys if available, otherwise public endpoints
api_key = os.getenv(f"{chain.upper()}_ETHERSCAN_KEY", "")
url = f"{base_url}?module=contract&action=getsourcecode&address={address}"
if api_key:
url += f"&apikey={api_key}"
data = await _fetch_url(url)
if data and data.get("status") == "1" and data.get("result"):
result = data["result"]
if isinstance(result, list) and len(result) > 0:
return result[0]
return result
return None
# ── Upgrade History ──────────────────────────────────────────────
async def fetch_upgrade_history(
address: str,
chain: str,
lookback_blocks: int = 100000,
) -> list[UpgradeEvent]:
"""Fetch proxy upgrade events from block explorer transaction history.
Looks for 'Upgraded' or 'UpgradeTo' event logs on the proxy contract.
"""
base_url = ETHERSCAN_BASES.get(chain)
if not base_url:
return []
api_key = os.getenv(f"{chain.upper()}_ETHERSCAN_KEY", "")
events = []
# Search for Upgraded event (EIP-1967): topic=0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b
upgrade_topic = "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b"
url = (
f"{base_url}?module=logs&action=getLogs"
f"&address={address}"
f"&topic0={upgrade_topic}"
f"&fromBlock=0&toBlock=latest"
f"&sort=desc"
)
if api_key:
url += f"&apikey={api_key}"
data = await _fetch_url(url)
if data and data.get("status") == "1" and data.get("result"):
for log in data["result"]:
if isinstance(log, dict):
upgrade = _parse_upgrade_log(log, address, chain)
if upgrade:
events.append(upgrade)
# Also check for admin-changing events
admin_topic = "0xf23ec0bb1e0758b0e3c00d38f1e1c1c0a8f6d4f4b0b8b8c2e4e8f0a0b0c0d0e"
admin_url = (
f"{base_url}?module=logs&action=getLogs"
f"&address={address}"
f"&topic0={admin_topic}"
f"&fromBlock=0&toBlock=latest"
f"&sort=desc"
)
if api_key:
admin_url += f"&apikey={api_key}"
admin_data = await _fetch_url(admin_url)
if admin_data and admin_data.get("status") == "1" and admin_data.get("result"):
for log in admin_data["result"]:
if isinstance(log, dict):
logger.debug("Admin event found for %s: %s", address[:10], log.get("transactionHash", "")[:20])
return events
def _parse_upgrade_log(log: dict, address: str, chain: str) -> UpgradeEvent | None:
"""Parse an upgrade event log entry."""
try:
tx_hash = log.get("transactionHash", "")
block_num = (
int(log.get("blockNumber", "0"), 16)
if log.get("blockNumber", "").startswith("0x")
else int(log.get("blockNumber", 0))
)
timestamp = (
int(log.get("timeStamp", "0"), 16)
if log.get("timeStamp", "").startswith("0x")
else int(log.get("timeStamp", 0))
)
# The 'data' field contains the new implementation address
data = log.get("data", "")
new_impl = None
if data and len(data) >= 66:
addr = "0x" + data[-40:]
if is_valid_evm_address(addr):
new_impl = addr
# Topic 1 (indexed param) might have previous implementation
topics = log.get("topics", [])
prev_impl = None
if len(topics) > 1:
addr = "0x" + topics[1][-40:]
if is_valid_evm_address(addr):
prev_impl = addr
return UpgradeEvent(
block_number=block_num,
transaction_hash=tx_hash,
timestamp=timestamp,
previous_implementation=prev_impl,
new_implementation=new_impl,
triggered_by=None, # Would need tx receipt for 'from'
)
except Exception as e:
logger.debug("Failed to parse upgrade log: %s", e)
return None
# ── Timelock Detection ──────────────────────────────────────────
async def check_timelock(address: str, chain: str) -> str | None:
"""Check if the proxy or its admin is behind a timelock.
Returns 'active', 'bypassed', 'no_timelock', or None if unknown.
"""
# Check if admin address is a known timelock contract
info = await detect_proxy(address, chain)
if not info.admin_address:
return None
admin = info.admin_address.lower()
# Common timelock contract signatures
# Check if admin is a known timelock address
source_info = await _fetch_source_code(admin, chain)
if source_info:
contract_name = (source_info.get("ContractName", "") or "").lower()
if "timelock" in contract_name:
return "active"
# Try to detect timelock by checking admin's own storage
# Active timelocks have a minimum delay > 0
try:
# Use storage slot to check if admin has timelock delay
delay_slot = "0x0000000000000000000000000000000000000000000000000000000000000002"
result = await read_storage_slot(admin, delay_slot, chain)
if result and int(result, 16) > 0:
return "active"
except Exception:
pass
return "no_timelock"
# ── Risk Assessment ──────────────────────────────────────────────
def _assess_risk(
proxy_info: ProxyInfo,
upgrades: list[UpgradeEvent],
current_time: int,
) -> tuple[float, list[str]]:
"""Assess risk based on proxy type, upgrade frequency, and protections."""
risk = 0.0
factors = []
# 1. No timelock is a risk
risk += 10.0
factors.append("No timelock detected - upgrades can be instant")
# 2. Beacon proxies have higher attack surface
if proxy_info.proxy_type == "beacon":
risk += 15.0
factors.append("Beacon proxy - multiple implementations share same beacon (wider attack surface)")
# 3. UUPS proxies - upgrade logic in implementation (higher risk if buggy)
if proxy_info.proxy_type == "eip1822":
risk += 10.0
factors.append(
"UUPS proxy - upgrade logic in implementation contract (vulnerable if implementation has upgrade bug)"
)
# 4. Recent upgrades increase risk
recent_count = sum(1 for u in upgrades if u.timestamp and current_time - u.timestamp < SUSPICIOUS_UPGRADE_WINDOW)
if recent_count > 0:
risk += min(recent_count * 15.0, 40.0)
factors.append(f"{recent_count} upgrade(s) in the last 24 hours")
# 5. Multiple upgrades in 30 days
thirty_days = 30 * 86400
month_count = sum(1 for u in upgrades if u.timestamp and current_time - u.timestamp < thirty_days)
if month_count > 5:
risk += 20.0
factors.append(f"Frequent upgrades: {month_count} upgrades in 30 days")
elif month_count > 2:
risk += 10.0
factors.append(f"Moderate upgrade frequency: {month_count} upgrades in 30 days")
# 6. Admin address not set (unusual for proxies)
if not proxy_info.admin_address and proxy_info.proxy_type:
risk += 5.0
factors.append("No admin address detected - upgrade authority unknown")
# 7. Implementation address hasn't changed (low risk)
upgrade_count = len(upgrades)
if upgrade_count == 0 and proxy_info.is_proxy:
risk = max(risk - 10.0, 5.0)
factors.append("No upgrade history - proxy is stable")
elif upgrade_count == 0:
factors.append("Not a proxy - no upgrade risk")
# Clamp
risk = max(0.0, min(100.0, risk))
return round(risk, 1), factors
# ── Main Analyzer ────────────────────────────────────────────────
class ContractUpgradeAnalyzer:
"""Main analyzer for contract upgrade monitoring."""
async def analyze(self, contract_address: str, chain: str = "ethereum") -> ContractUpgradeReport:
"""Run complete upgrade monitoring analysis on a contract."""
report = ContractUpgradeReport(
contract_address=contract_address,
chain=chain,
)
if not is_valid_evm_address(contract_address):
raise ValueError(f"Invalid EVM address: {contract_address}")
current_time = int(time.time())
# Step 1: Detect proxy
proxy_info = await detect_proxy(contract_address, chain)
report.proxy_info = proxy_info
if proxy_info.is_proxy:
# Step 2: Check timelock
timelock_status = await check_timelock(contract_address, chain)
report.timelock_status = timelock_status
# Step 3: Fetch upgrade history
upgrades = await fetch_upgrade_history(contract_address, chain)
report.upgrade_history = upgrades
report.upgrade_count_30d = sum(
1 for u in upgrades if u.timestamp and current_time - u.timestamp < 30 * 86400
)
# Step 4: Identify recent suspicious upgrades
report.recent_suspicious_upgrades = [
u for u in upgrades if u.timestamp and current_time - u.timestamp < SUSPICIOUS_UPGRADE_WINDOW
]
# Step 5: Implementation age
if upgrades:
last_upgrade = max(upgrades, key=lambda u: u.timestamp or 0)
report.last_upgrade_time = last_upgrade.timestamp
if last_upgrade.timestamp:
report.implementation_age_days = (current_time - last_upgrade.timestamp) // 86400
# Step 6: Admin privileges
if proxy_info.detected_selectors:
report.admin_privileges = [DANGEROUS_SELECTORS.get(s, s) for s in proxy_info.detected_selectors]
# Step 7: Risk assessment
risk, factors = _assess_risk(proxy_info, upgrades, current_time)
report.risk_score = risk
report.risk_factors = factors
# Generate summary
summary_parts = [f"Contract **{contract_address[:10]}...** on **{chain.title()}**"]
if proxy_info.proxy_type_name:
summary_parts.append(f"is a **{proxy_info.proxy_type_name}**")
if proxy_info.implementation_address:
summary_parts.append(f"→ implementation at **{proxy_info.implementation_address[:10]}...**")
if report.upgrade_count_30d > 0:
summary_parts.append(f"| **{report.upgrade_count_30d}** upgrades in 30 days")
summary_parts.append(f"| Risk: **{report.risk_score}/100**")
report.summary = " ".join(summary_parts)
else:
report.summary = (
f"Contract **{contract_address[:10]}...** on **{chain.title()}** "
f"does not appear to be a proxy contract. "
f"Risk: **0/100** (no upgrade risk)"
)
report.risk_score = 0.0
return report
# ── Formatter ────────────────────────────────────────────────────
def format_upgrade_report(report: ContractUpgradeReport) -> str:
"""Format the upgrade monitoring report for human-readable output."""
lines = []
lines.append("=" * 60)
lines.append("CONTRACT UPGRADE MONITOR REPORT")
lines.append("=" * 60)
lines.append(f"Contract: {report.contract_address}")
lines.append(f"Chain: {report.chain.title()}")
lines.append("")
pi = report.proxy_info
if pi and pi.is_proxy:
lines.append(f"🔍 Proxy Detected: {pi.proxy_type_name or pi.proxy_type or 'Unknown'}")
if pi.implementation_address:
lines.append(f" Implementation: {pi.implementation_address}")
if pi.admin_address:
lines.append(f" Admin: {pi.admin_address}")
if pi.beacon_address:
lines.append(f" Beacon: {pi.beacon_address}")
lines.append(f" Confidence: {pi.confidence:.0%}")
lines.append("")
lines.append(f"⏱ Timelock: {report.timelock_status or 'Unknown'}")
lines.append("")
if report.upgrade_history:
lines.append(f"📜 Upgrade History ({len(report.upgrade_history)} total):")
for u in report.upgrade_history[:10]: # Show last 10
ts_str = ""
if u.timestamp:
from datetime import datetime
ts_str = datetime.utcfromtimestamp(u.timestamp).strftime("%Y-%m-%d %H:%M UTC")
lines.append(f" TX: {u.transaction_hash[:20]}... | {ts_str}")
if u.new_implementation:
lines.append(f" New impl: {u.new_implementation[:20]}...")
if len(report.upgrade_history) > 10:
lines.append(f" ... and {len(report.upgrade_history) - 10} more")
lines.append("")
else:
lines.append("📜 No upgrade events found in explorer logs")
lines.append("")
lines.append(f"📈 Upgrade Count (30d): {report.upgrade_count_30d}")
if report.implementation_age_days is not None:
lines.append(f"📅 Current Impl Age: {report.implementation_age_days} days")
lines.append("")
if report.admin_privileges:
lines.append("🔐 Admin Privileges Detected:")
for priv in report.admin_privileges[:5]:
lines.append(f"{priv}")
lines.append("")
lines.append("⚠️ RISK ASSESSMENT")
lines.append(f" Score: {report.risk_score}/100")
for factor in report.risk_factors:
lines.append(f"{factor}")
else:
lines.append("✅ Not a proxy contract - no upgrade risk detected.")
if pi and pi.detected_selectors:
lines.append(" (Proxy-like selectors found but no storage slot match)")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
# ── Singleton factory ────────────────────────────────────────────
_analyzer_instance: ContractUpgradeAnalyzer | None = None
def get_upgrade_analyzer() -> ContractUpgradeAnalyzer:
"""Get or create a singleton ContractUpgradeAnalyzer instance."""
global _analyzer_instance
if _analyzer_instance is None:
_analyzer_instance = ContractUpgradeAnalyzer()
return _analyzer_instance