- 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>
225 lines
6.9 KiB
Python
225 lines
6.9 KiB
Python
"""
|
|
x402 Router: launch_fairness
|
|
==============================
|
|
Wraps Launch Fairness Analyzer with:
|
|
- Address validation
|
|
- x402 payment middleware integration
|
|
- Caching (Redis if available)
|
|
- Trial quota tracking
|
|
- Rate limiting support
|
|
|
|
TOOL : launch_fairness
|
|
TIER : premium
|
|
PRICE : $0.10 (100000 atoms)
|
|
TRIAL : 2 free checks
|
|
ROUTER: /api/v1/x402-tools/launch_fairness
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from contextlib import suppress
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
from app.launch_fairness_analyzer import analyze_launch_fairness
|
|
|
|
logger = logging.getLogger("x402_launch_fairness")
|
|
|
|
router = APIRouter(prefix="/api/v1/x402-tools", tags=["x402-tools"])
|
|
|
|
# ── Redis cache (best-effort) ───────────────────────────────────
|
|
_redis = None
|
|
try:
|
|
import redis.asyncio as aioredis
|
|
|
|
_redis = aioredis.from_url(
|
|
os.getenv("REDIS_URL", "redis://localhost:6379/0"),
|
|
decode_responses=True,
|
|
socket_connect_timeout=2,
|
|
)
|
|
except Exception:
|
|
logger.debug("Redis not available for launch_fairness cache")
|
|
|
|
|
|
async def _get_cache(key: str) -> dict[str, Any] | None:
|
|
"""Get cached result if Redis is available."""
|
|
if _redis is None:
|
|
return None
|
|
with suppress(Exception):
|
|
data = await _redis.get(f"x402:cache:launch_fairness:{key}")
|
|
if data:
|
|
result: dict[str, Any] = json.loads(data)
|
|
return result
|
|
return None
|
|
|
|
|
|
async def _set_cache(key: str, result: dict[str, Any], ttl: int = 300) -> None:
|
|
"""Cache result in Redis with TTL."""
|
|
if _redis is None:
|
|
return
|
|
with suppress(Exception):
|
|
await _redis.setex(
|
|
f"x402:cache:launch_fairness:{key}", ttl, json.dumps(result, default=str)
|
|
)
|
|
|
|
|
|
# ── Trial tracking ──────────────────────────────────────────────
|
|
|
|
|
|
def _trial_key(wallet: str) -> str:
|
|
return f"x402:launch_fairness_trials:{wallet}"
|
|
|
|
|
|
async def _check_trials(wallet: str) -> int:
|
|
"""Check how many trials this wallet has used."""
|
|
if _redis is None:
|
|
return 0
|
|
try:
|
|
used = await _redis.get(_trial_key(wallet))
|
|
return int(used) if used else 0
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
async def _increment_trials(wallet: str) -> None:
|
|
"""Increment trial usage for a wallet."""
|
|
if _redis is None:
|
|
return
|
|
try:
|
|
await _redis.incr(_trial_key(wallet))
|
|
await _redis.expire(_trial_key(wallet), 86400 * 30) # Reset monthly
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ── Request / Response models ───────────────────────────────────
|
|
|
|
|
|
class LaunchFairnessRequest(BaseModel):
|
|
"""Request model for launch fairness analysis."""
|
|
|
|
address: str = Field(..., description="Token contract address to analyze")
|
|
chain: str = Field(
|
|
"auto",
|
|
description="Blockchain (ethereum, solana, bsc, base, polygon, or 'auto' for detection)",
|
|
)
|
|
simulate_data: bool = Field(True, description="Use simulated data for demonstration / testing")
|
|
|
|
@field_validator("address")
|
|
@classmethod
|
|
def validate_address(cls, v: str) -> str:
|
|
v = v.strip()
|
|
is_evm = v.startswith("0x") and len(v) == 42
|
|
is_solana = not v.startswith("0x") and 32 <= len(v) <= 44 and v.isascii()
|
|
if not is_evm and not is_solana:
|
|
raise ValueError(
|
|
"Address must be a valid token contract address (0x... for EVM, base58 for Solana)"
|
|
)
|
|
return v.lower()
|
|
|
|
@field_validator("chain")
|
|
@classmethod
|
|
def validate_chain(cls, v: str) -> str:
|
|
valid_chains = {
|
|
"auto",
|
|
"ethereum",
|
|
"solana",
|
|
"bsc",
|
|
"base",
|
|
"polygon",
|
|
"arbitrum",
|
|
"avalanche",
|
|
}
|
|
v = v.lower().strip()
|
|
if v not in valid_chains:
|
|
raise ValueError(f"Chain must be one of: {', '.join(sorted(valid_chains))}")
|
|
return v
|
|
|
|
|
|
class LaunchFairnessResponse(BaseModel):
|
|
"""Response model for launch fairness analysis."""
|
|
|
|
success: bool = True
|
|
tool: str = "launch_fairness"
|
|
data: dict[str, Any] = Field(default_factory=dict)
|
|
cached: bool = False
|
|
scanned_at: str = ""
|
|
|
|
|
|
# ── Endpoint ────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/launch_fairness")
|
|
async def analyze_launch_fairness_endpoint(
|
|
request: Request,
|
|
body: LaunchFairnessRequest,
|
|
) -> LaunchFairnessResponse:
|
|
"""
|
|
Analyze token launch fairness - detect sniped distributions, bundled launches,
|
|
bot activity, LP manipulation, and presale concentration.
|
|
|
|
Returns a fairness score (0-100) with per-signal breakdown and evidence.
|
|
"""
|
|
start = time.time()
|
|
wallet = request.headers.get("x-wallet-address", "anonymous")
|
|
|
|
# ── Trial check ──
|
|
max_trial = 2
|
|
trials_used = await _check_trials(wallet)
|
|
if trials_used < max_trial:
|
|
await _increment_trials(wallet)
|
|
|
|
# ── Cache check ──
|
|
cache_key = f"{body.address}:{body.chain}:{body.simulate_data}"
|
|
cached = await _get_cache(cache_key)
|
|
if cached:
|
|
elapsed = time.time() - start
|
|
logger.info(f"Launch fairness cache hit for {body.address[:12]}... ({elapsed:.2f}s)")
|
|
return LaunchFairnessResponse(
|
|
data=cached,
|
|
cached=True,
|
|
scanned_at=cached.get("scanned_at", ""),
|
|
)
|
|
|
|
# ── Run analysis ──
|
|
try:
|
|
result = await analyze_launch_fairness(
|
|
token_address=body.address,
|
|
chain=body.chain,
|
|
simulate_data=body.simulate_data,
|
|
)
|
|
|
|
# Add metadata
|
|
result["scanned_at"] = datetime.now(tz=UTC).isoformat()
|
|
result["tier"] = "premium"
|
|
result["price_usd"] = 0.10
|
|
|
|
# ── Cache result (short TTL - fairness data changes quickly) ──
|
|
await _set_cache(cache_key, result, ttl=120)
|
|
|
|
logger.info(
|
|
f"Launch fairness analyzed {body.address[:12]}... "
|
|
f"score={result.get('fairness_score', '?')}% "
|
|
f"({time.time() - start:.2f}s)"
|
|
)
|
|
|
|
return LaunchFairnessResponse(
|
|
data=result,
|
|
cached=False,
|
|
scanned_at=result["scanned_at"],
|
|
)
|
|
|
|
except ValueError as e:
|
|
logger.warning(f"Launch fairness validation error: {e}")
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
except Exception as e:
|
|
logger.error(f"Launch fairness analysis failed: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Launch fairness analysis failed. Please try again.",
|
|
) from e
|