759 lines
29 KiB
Python
759 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:
|
|
from eth_account import Account # noqa: F401
|
|
|
|
# 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
|