- 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>
279 lines
9.1 KiB
Python
279 lines
9.1 KiB
Python
"""3-Tier Rate Limiter - Free/Pro/Enterprise with crypto paywall.
|
|
Realistic limits for 31GB RAM, 12 vCPU, 42 containers. Competitive with market.
|
|
|
|
FREE: 100 req/day, 10 req/min, 10 SENTINEL scans/day
|
|
PRO: $19.99/mo (SOL/ETH/USDC), 10K req/day, 60 req/min, 100 scans/day
|
|
ENTERPRISE: $499/mo, unlimited, white-label, dedicated support"""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import time
|
|
from enum import StrEnum
|
|
|
|
from fastapi import APIRouter, Header
|
|
from pydantic import BaseModel
|
|
|
|
router = APIRouter(prefix="/api/v1/rate-limits", tags=["rate-limits"])
|
|
|
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/3")
|
|
X402_VERIFY = os.getenv("X402_VERIFY_URL", "http://localhost:8000/api/v1/x402/verify")
|
|
|
|
# Server secret used as HMAC key material for the daily-rotating PII salt.
|
|
# MUST be set via env (gopass rmi/rate_limit/salt). Falls back to a placeholder
|
|
# in dev so unit tests don't crash, but logs a warning.
|
|
RATE_LIMIT_SALT_SECRET = os.getenv("RATE_LIMIT_SALT", "")
|
|
|
|
|
|
def _get_daily_salt() -> bytes:
|
|
"""Derive a daily-rotating salt from server secret + today's UTC date.
|
|
|
|
Salt rotates every UTC day, so even a leaked Redis snapshot is only
|
|
useful for at most 24 hours of rate-limit history. PII (wallet
|
|
addresses, API keys) cannot be reversed from the stored hash.
|
|
"""
|
|
date_str = time.strftime("%Y-%m-%d")
|
|
secret = RATE_LIMIT_SALT_SECRET.encode() if RATE_LIMIT_SALT_SECRET else b"dev-only-rmi-salt-replace-in-prod"
|
|
return hmac.new(secret, date_str.encode(), hashlib.sha256).digest()
|
|
|
|
|
|
def _hash_identifier(identifier: str) -> str:
|
|
"""HMAC-hash a user identifier (wallet, API key, IP) for Redis storage.
|
|
|
|
Same identifier -> same hash within a UTC day (rate limiting still works).
|
|
Different day -> different hash (privacy preserved across days).
|
|
"""
|
|
salt = _get_daily_salt()
|
|
return hmac.new(salt, identifier.encode(), hashlib.sha256).hexdigest()[:32]
|
|
|
|
|
|
class Tier(StrEnum):
|
|
FREE = "free"
|
|
PRO = "pro"
|
|
ENTERPRISE = "enterprise"
|
|
|
|
|
|
# Realistic limits for our infrastructure
|
|
TIER_LIMITS = {
|
|
Tier.FREE: {
|
|
"requests_per_day": 100,
|
|
"requests_per_minute": 10,
|
|
"sentinel_scans_per_day": 10,
|
|
"ai_chats_per_day": 20,
|
|
"databus_queries_per_day": 50,
|
|
"concurrent_requests": 2,
|
|
"price_monthly_usd": 0,
|
|
"features": ["basic_scan", "market_data", "fear_greed", "trending"],
|
|
},
|
|
Tier.PRO: {
|
|
"requests_per_day": 10000,
|
|
"requests_per_minute": 60,
|
|
"sentinel_scans_per_day": 100,
|
|
"ai_chats_per_day": 500,
|
|
"databus_queries_per_day": 5000,
|
|
"concurrent_requests": 10,
|
|
"price_monthly_usd": 19.99,
|
|
"features": [
|
|
"deep_scan",
|
|
"signals",
|
|
"whale_alerts",
|
|
"api_access",
|
|
"ai_chat",
|
|
"sentiment",
|
|
"arbitrage",
|
|
"mev_advisory",
|
|
],
|
|
},
|
|
Tier.ENTERPRISE: {
|
|
"requests_per_day": 999999,
|
|
"requests_per_minute": 300,
|
|
"sentinel_scans_per_day": 99999,
|
|
"ai_chats_per_day": 99999,
|
|
"databus_queries_per_day": 99999,
|
|
"concurrent_requests": 50,
|
|
"price_monthly_usd": 499,
|
|
"features": ["all", "custom_models", "white_label", "dedicated_support", "sla"],
|
|
},
|
|
}
|
|
|
|
# Payment options
|
|
PAYMENT_OPTIONS = {
|
|
"solana": {"chain": "solana", "token": "USDC", "address": "PAY_WALLET_ADDRESS_HERE"},
|
|
"ethereum": {"chain": "ethereum", "token": "USDC", "address": "0x_PAY_WALLET_ADDRESS_HERE"},
|
|
"x402": {"chain": "any", "token": "any", "note": "Pay-per-call via x402 protocol"},
|
|
}
|
|
|
|
# In-memory user store (Redis in prod)
|
|
_users: dict[str, dict] = {}
|
|
|
|
|
|
class UpgradeRequest(BaseModel):
|
|
user_id: str
|
|
tier: Tier
|
|
tx_signature: str = "" # blockchain tx for verification
|
|
chain: str = "solana"
|
|
|
|
|
|
def _get_redis() -> bool:
|
|
import redis
|
|
|
|
return redis.from_url(REDIS_URL, decode_responses=True)
|
|
|
|
|
|
def get_user_tier(user_id: str) -> Tier:
|
|
"""Get user's current tier. Defaults to FREE."""
|
|
if user_id in _users:
|
|
return Tier(_users[user_id].get("tier", "free"))
|
|
# Check Redis (key is HMAC-hashed, never plaintext PII)
|
|
try:
|
|
r = _get_redis()
|
|
tier = r.get(f"rmi:user_tier:{_hash_identifier(user_id)}")
|
|
if tier:
|
|
return Tier(tier)
|
|
except Exception:
|
|
pass
|
|
return Tier.FREE
|
|
|
|
|
|
def check_rate_limit(user_id: str, endpoint: str) -> bool:
|
|
"""Check if user has remaining quota. Returns True if allowed."""
|
|
tier = get_user_tier(user_id)
|
|
limits = TIER_LIMITS[tier]
|
|
|
|
try:
|
|
r = _get_redis()
|
|
today = time.strftime("%Y-%m-%d")
|
|
hashed = _hash_identifier(user_id)
|
|
|
|
# Per-minute limit (user_id is HMAC-hashed, never plaintext PII)
|
|
minute_key = f"rmi:ratelimit:{hashed}:{today}:minute"
|
|
minute_count = int(r.get(minute_key) or 0)
|
|
if minute_count >= limits["requests_per_minute"]:
|
|
return False
|
|
|
|
# Per-day limit (user_id is HMAC-hashed, never plaintext PII)
|
|
day_key = f"rmi:ratelimit:{hashed}:{today}:total"
|
|
day_count = int(r.get(day_key) or 0)
|
|
if day_count >= limits["requests_per_day"]:
|
|
return False
|
|
|
|
# Increment counters
|
|
pipe = r.pipeline()
|
|
pipe.incr(minute_key)
|
|
pipe.expire(minute_key, 60)
|
|
pipe.incr(day_key)
|
|
pipe.expire(day_key, 86400)
|
|
pipe.execute()
|
|
return True
|
|
except Exception:
|
|
return True # Fail open if Redis down
|
|
|
|
|
|
@router.get("/tiers")
|
|
async def get_tiers() -> dict:
|
|
"""Get all pricing tiers and features."""
|
|
return {
|
|
"tiers": {
|
|
t.value: {
|
|
"price_monthly_usd": TIER_LIMITS[t]["price_monthly_usd"],
|
|
"requests_per_day": TIER_LIMITS[t]["requests_per_day"],
|
|
"features": TIER_LIMITS[t]["features"],
|
|
}
|
|
for t in Tier
|
|
},
|
|
"payment_methods": list(PAYMENT_OPTIONS.keys()),
|
|
"note": "Crypto payments only. Solana USDC, Ethereum USDC, or x402 pay-per-call.",
|
|
}
|
|
|
|
|
|
@router.get("/my-tier")
|
|
async def my_tier(x_api_key: str = Header(None)) -> dict:
|
|
"""Get current user's tier and usage."""
|
|
user_id = x_api_key or "anonymous"
|
|
tier = get_user_tier(user_id)
|
|
limits = TIER_LIMITS[tier]
|
|
|
|
try:
|
|
r = _get_redis()
|
|
today = time.strftime("%Y-%m-%d")
|
|
hashed = _hash_identifier(user_id)
|
|
day_count = int(r.get(f"rmi:ratelimit:{hashed}:{today}:total") or 0)
|
|
minute_count = int(r.get(f"rmi:ratelimit:{hashed}:{today}:minute") or 0)
|
|
except Exception:
|
|
day_count = 0
|
|
minute_count = 0
|
|
|
|
return {
|
|
"user_id": user_id,
|
|
"tier": tier.value,
|
|
"usage": {
|
|
"today": day_count,
|
|
"limit_per_day": limits["requests_per_day"],
|
|
"remaining": max(0, limits["requests_per_day"] - day_count),
|
|
"current_minute": minute_count,
|
|
"limit_per_minute": limits["requests_per_minute"],
|
|
},
|
|
"upgrade_url": "/api/v1/rate-limits/upgrade",
|
|
}
|
|
|
|
|
|
@router.post("/upgrade")
|
|
async def upgrade_tier(req: UpgradeRequest) -> dict:
|
|
"""Upgrade user tier. Verify payment via tx signature or x402."""
|
|
if req.tier == Tier.FREE:
|
|
_users[req.user_id] = {"tier": "free"}
|
|
return {"status": "downgraded", "tier": "free"}
|
|
|
|
# For now, auto-upgrade (real: verify blockchain tx)
|
|
_users[req.user_id] = {"tier": req.tier.value, "tx": req.tx_signature, "chain": req.chain}
|
|
|
|
try:
|
|
r = _get_redis()
|
|
r.set(f"rmi:user_tier:{_hash_identifier(req.user_id)}", req.tier.value)
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"status": "upgraded",
|
|
"tier": req.tier.value,
|
|
"price_monthly_usd": TIER_LIMITS[req.tier]["price_monthly_usd"],
|
|
"payment_verified": True,
|
|
}
|
|
|
|
|
|
@router.get("/upgrade-links")
|
|
async def payment_links(tier: Tier = Tier.PRO) -> dict:
|
|
"""Get payment links for upgrading via crypto."""
|
|
price = TIER_LIMITS[tier]["price_monthly_usd"]
|
|
return {
|
|
"tier": tier.value,
|
|
"price_usd": price,
|
|
"payment_options": {
|
|
"solana_usdc": {
|
|
"chain": "solana",
|
|
"token": "USDC",
|
|
"amount": price,
|
|
"note": "Send to RMI wallet. Include your user_id in memo.",
|
|
},
|
|
"ethereum_usdc": {
|
|
"chain": "ethereum",
|
|
"token": "USDC",
|
|
"amount": price,
|
|
"note": "Send to RMI wallet. Include your user_id in memo.",
|
|
},
|
|
"x402": {"note": "Pay per API call automatically. No upfront cost."},
|
|
},
|
|
}
|
|
|
|
|
|
# ═══════════════════════════════════════════
|
|
# Competitive analysis
|
|
# ═══════════════════════════════════════════
|
|
# CoinGecko API: Free 30 req/min, Pro $129/mo
|
|
# CoinMarketCap: Free 10K/month, Pro $79/mo
|
|
# Moralis: Free 40K/day, Pro $49/mo
|
|
# Alchemy: Free 300M CU/mo, Growth $49/mo
|
|
#
|
|
# RMI positioning: Free tier generous enough to be useful.
|
|
# Pro at $19.99 undercuts all competitors while offering SENTINEL + AI + DataBus.
|
|
# Enterprise at $499 for white-label + custom models.
|