rmi-backend/app/routers/x402_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

189 lines
6.5 KiB
Python

"""
x402 Router: contract_upgrade_monitor
========================================
Wraps ContractUpgradeAnalyzer with:
- Address validation
- x402 payment middleware integration
- Caching (Redis if available)
- Trial quota tracking
- Rate limiting support
TOOL : contract_upgrade_monitor
TIER : security
PRICE : $0.05 (50000 atoms)
TRIAL : 2 free checks
ROUTER: /api/v1/x402-tools/contract_upgrade_monitor
"""
from __future__ import annotations
import json
import logging
import os
from contextlib import suppress
import redis as _redis_mod
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field, field_validator
from app.contract_upgrade_monitor import (
format_upgrade_report,
get_upgrade_analyzer,
is_valid_evm_address,
)
logger = logging.getLogger("x402_contract_upgrade_monitor")
router = APIRouter(prefix="/api/v1/x402-tools", tags=["x402-tools"])
# ── Redis helpers ─────────────────────────────────────────────
_redis: _redis_mod.Redis | None = None
def _get_redis():
global _redis
if _redis is None:
try:
r = _redis_mod.Redis(
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", 6379)),
db=int(os.getenv("REDIS_DB", 0)),
password=os.getenv("REDIS_PASSWORD", None),
decode_responses=True,
)
r.ping()
_redis = r
except Exception:
_redis = None # Not available
return _redis
_CACHE_TTL = 600 # 10 minutes - upgrade data doesn't change rapidly
# ── Request Models ─────────────────────────────────────────────
class ContractUpgradeRequest(BaseModel):
"""Request body for contract upgrade monitoring."""
contract_address: str = Field(
...,
description="EVM contract address to check for proxy upgrade risk",
min_length=42,
max_length=42,
)
chain: str = Field(
default="ethereum",
description="Chain name (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche)",
)
@field_validator("contract_address")
@classmethod
def validate_address(cls, v: str) -> str:
v = v.strip()
if not is_valid_evm_address(v):
raise ValueError(f"Invalid EVM address: {v}. Must be a 0x-prefixed 40-char hex address.")
return v
@field_validator("chain")
@classmethod
def validate_chain(cls, v: str) -> str:
valid = {"ethereum", "bsc", "polygon", "arbitrum", "optimism", "base", "avalanche"}
v = v.strip().lower()
if v not in valid:
raise ValueError(f"Unsupported chain: {v}. Supported: {', '.join(sorted(valid))}")
return v
# ── Endpoints ──────────────────────────────────────────────────
@router.post("/contract_upgrade_monitor")
async def analyze_contract_upgrades(request: Request, body: ContractUpgradeRequest) -> dict:
"""
Monitor proxy contract upgrades for malicious implementation swaps.
Detects EIP-1967, EIP-1822 UUPS, Beacon, and Gnosis Safe proxies.
Returns upgrade history, timelock status, and risk assessment (0-100).
"""
address = body.contract_address.strip()
chain = body.chain.strip().lower()
# Check cache
cache_key = f"contract_upgrade_monitor:{address}:{chain}"
r = _get_redis()
if r:
with suppress(Exception):
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# Check trial quota (via x402 middleware headers if available)
trial_used = False
x402_headers = getattr(request.state, "x402", None) or {}
quota = x402_headers.get("remaining_quota", {})
if isinstance(quota, dict) and quota.get("contract_upgrade_monitor", 0) > 0:
trial_used = True
logger.info(
"Trial quota used for contract_upgrade_monitor on %s (%s)",
address[:10],
chain,
)
try:
analyzer = get_upgrade_analyzer()
report = await analyzer.analyze(
contract_address=address,
chain=chain,
)
response = {
"success": True,
"tool": "contract_upgrade_monitor",
"contract_address": address,
"chain": chain,
"is_proxy": report.proxy_info.is_proxy if report.proxy_info else False,
"proxy_type": report.proxy_info.proxy_type if report.proxy_info else None,
"proxy_type_name": report.proxy_info.proxy_type_name if report.proxy_info else None,
"implementation_address": report.proxy_info.implementation_address if report.proxy_info else None,
"admin_address": report.proxy_info.admin_address if report.proxy_info else None,
"beacon_address": report.proxy_info.beacon_address if report.proxy_info else None,
"timelock_status": report.timelock_status,
"upgrade_count_30d": report.upgrade_count_30d,
"upgrade_history_count": len(report.upgrade_history),
"recent_suspicious_upgrades": len(report.recent_suspicious_upgrades),
"implementation_age_days": report.implementation_age_days,
"admin_privileges": report.admin_privileges[:10],
"risk_score": report.risk_score,
"risk_factors": report.risk_factors[:10],
"summary": report.summary,
"report_text": format_upgrade_report(report),
"trial_used": trial_used,
}
# Cache only paid results (not trial)
if not trial_used and r:
with suppress(Exception):
r.setex(cache_key, _CACHE_TTL, json.dumps(response))
return response
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
logger.exception("Contract upgrade monitor failed for %s on %s", address[:10], chain)
raise HTTPException(
status_code=500,
detail=f"Analysis failed: {e!s}",
) from e
@router.get("/contract_upgrade_monitor/health")
async def contract_upgrade_health() -> dict:
"""Health check endpoint."""
return {
"status": "ok",
"tool": "contract_upgrade_monitor",
"version": "1.0.0",
}