| """ |
| RMI Developer Tier System |
| ========================== |
| Free developer API keys with 100 calls/day. No payment required. |
| Designed to get bot developers hooked on RMI tools before upgrading to paid. |
| |
| Tiers: |
| FREE: 100 calls/day, rate-limited 10/min, all tools accessible |
| TRIAL: 3-5 calls per tool (existing system), no key needed |
| PAID: Pay-per-use via x402, no rate limit beyond burst protection |
| PRO: $29/month, 5000 calls/day, priority queue, webhook support |
| |
| Author: RMI Development |
| Date: 2026-06-05 |
| """ |
|
|
| import json |
| import logging |
| import time |
| from datetime import UTC, datetime, timedelta |
| from typing import Any |
|
|
| from fastapi import APIRouter, Request |
| from fastapi.responses import JSONResponse |
| from pydantic import BaseModel, Field |
|
|
| logger = logging.getLogger("developer_tier") |
|
|
| router = APIRouter(prefix="/api/v1/developer", tags=["developer-tier"]) |
|
|
| |
|
|
|
|
| def verify_evm_signature(address: str, message: str, signature: str) -> bool: |
| """Verify an EVM wallet signature. |
| |
| Uses web3.py to recover the signer from the signature and compare |
| with the claimed address. This proves wallet ownership. |
| """ |
| try: |
| from eth_account import Account |
| from eth_account.messages import encode_defunct |
|
|
| encoded = encode_defunct(text=message) |
| recovered = Account.recover_message(encoded, signature=signature) |
| return recovered.lower() == address.lower() |
| except ImportError: |
| |
| return False |
| except Exception: |
| return False |
|
|
|
|
| def verify_solana_signature(address: str, message: str, signature: str) -> bool: |
| """Verify a Solana wallet signature.""" |
| try: |
| import base64 |
|
|
| from nacl.signing import VerifyKey |
|
|
| from app.core.redis import get_redis |
|
|
| pubkey = VerifyKey(base64.b58decode(address)) |
| msg_bytes = message.encode("utf-8") |
| sig_bytes = base64.b64decode(signature) |
| pubkey.verify(msg_bytes, sig_bytes) |
| return True |
| except ImportError: |
| return False |
| except Exception: |
| return False |
|
|
|
|
| |
|
|
| TIERS = { |
| "free": { |
| "daily_limit": 100, |
| "rate_limit_per_min": 10, |
| "rate_limit_per_hour": 200, |
| "tools": "all", |
| "price_usd": 0, |
| "trial_free": 0, |
| "webhook_support": False, |
| "priority_queue": False, |
| "analytics_access": False, |
| }, |
| "trial": { |
| "daily_limit": 20, |
| "rate_limit_per_min": 5, |
| "rate_limit_per_hour": 50, |
| "tools": "all", |
| "price_usd": 0, |
| "trial_free": 3, |
| "webhook_support": False, |
| "priority_queue": False, |
| "analytics_access": False, |
| }, |
| "paid": { |
| "daily_limit": 10000, |
| "rate_limit_per_min": 60, |
| "rate_limit_per_hour": 2000, |
| "tools": "all", |
| "price_usd": "per_call", |
| "trial_free": 0, |
| "webhook_support": True, |
| "priority_queue": False, |
| "analytics_access": True, |
| }, |
| "pro": { |
| "daily_limit": 5000, |
| "rate_limit_per_min": 120, |
| "rate_limit_per_hour": 5000, |
| "tools": "all", |
| "price_usd": 29.00, |
| "trial_free": 0, |
| "webhook_support": True, |
| "priority_queue": True, |
| "analytics_access": True, |
| }, |
| } |
|
|
|
|
| |
|
|
|
|
| def create_tier(tier: str) -> dict[str, Any] | None: |
| """Create a new developer API key.""" |
| r = get_redis() |
| if not r: |
| return None |
|
|
| if tier not in TIERS: |
| return None |
|
|
| key = generate_api_key() |
| hashed = hash_api_key(key) |
| created_at = datetime.now(UTC).isoformat() |
|
|
| key_info = { |
| "key": key, |
| "hashed_key": hashed, |
| "email": email, |
| "tier": tier, |
| "wallet_address": wallet_address, |
| "created_at": created_at, |
| "status": "active", |
| "total_calls": 0, |
| "total_revenue_usd": 0, |
| } |
|
|
| |
| r.set( |
| f"rmi:dev:key:{hashed}", |
| json.dumps(key_info), |
| ex=86400 * 90 if tier == "trial" else None, |
| ) |
|
|
| |
| r.sadd(f"rmi:dev:email:{email}", hashed) |
|
|
| |
| r.incr("rmi:dev:stats:total_keys") |
| r.incr(f"rmi:dev:stats:tier:{tier}") |
|
|
| return key_info |
|
|
|
|
| def get_usage_for_key(hashed_key: str) -> dict[str, Any]: |
| """Get usage stats for an API key.""" |
| r = get_redis() |
| if not r: |
| return {"error": "redis_unavailable"} |
|
|
| today = datetime.now(UTC).strftime("%Y-%m-%d") |
| today_calls = int(r.get(f"rmi:dev:usage:{hashed_key}:{today}") or 0) |
| total_calls = int(r.get(f"rmi:dev:usage:{hashed_key}:total") or 0) |
|
|
| |
| key_data = r.get(f"rmi:dev:key:{hashed_key}") |
| if not key_data: |
| return {"error": "key_not_found"} |
|
|
| key_info = json.loads(key_data) |
| tier_config = TIERS.get(key_info.get("tier", "free"), TIERS["free"]) |
|
|
| return { |
| "tier": key_info.get("tier", "free"), |
| "email": key_info.get("email", ""), |
| "status": key_info.get("status", "active"), |
| "today_calls": today_calls, |
| "daily_limit": tier_config["daily_limit"], |
| "remaining_today": max(0, tier_config["daily_limit"] - today_calls), |
| "total_calls": total_calls, |
| "created_at": key_info.get("created_at", ""), |
| "rate_limit_per_min": tier_config["rate_limit_per_min"], |
| } |
|
|
|
|
| |
|
|
|
|
| class RegisterRequest(BaseModel): |
| email: str = Field(..., min_length=3, max_length=255) |
| tier: str = Field(default="free") |
| wallet_address: str | None = None |
|
|
|
|
| class VerifyRequest(BaseModel): |
| api_key: str |
|
|
|
|
| class UsageRequest(BaseModel): |
| api_key: str |
|
|
|
|
| |
|
|
|
|
| @router.post("/register") |
| async def register_developer(req: RegisterRequest): |
| """Register for a free developer API key. 100 calls/day, no payment needed.""" |
| |
| if req.tier not in TIERS: |
| return JSONResponse( |
| status_code=400, |
| content={ |
| "error": f"Invalid tier. Available: {', '.join(TIERS.keys())}", |
| "tiers": {k: {"daily_limit": v["daily_limit"], "price": v["price_usd"]} for k, v in TIERS.items()}, |
| }, |
| ) |
|
|
| |
| if "@" not in req.email or "." not in req.email.split("@")[-1]: |
| return JSONResponse(status_code=400, content={"error": "Invalid email address"}) |
|
|
| |
| r = get_redis() |
| if r: |
| existing = r.smembers(f"rmi:dev:email:{req.email}") |
| if existing: |
| return JSONResponse( |
| status_code=409, |
| content={ |
| "error": "Email already registered", |
| "message": "You already have an API key. Use /verify to check your key status.", |
| }, |
| ) |
|
|
| |
| result = create_api_key(req.email, req.tier, req.wallet_address) |
| if not result: |
| return JSONResponse(status_code=500, content={"error": "Failed to create API key"}) |
|
|
| |
| return JSONResponse( |
| status_code=201, |
| content={ |
| "success": True, |
| "message": "Developer API key created. Save it β it won't be shown again.", |
| "api_key": result["key"], |
| "tier": result["tier"], |
| "daily_limit": TIERS[result["tier"]]["daily_limit"], |
| "rate_limit_per_min": TIERS[result["tier"]]["rate_limit_per_min"], |
| "documentation": "https://rugmunch.io/mcp-docs", |
| "quickstart": { |
| "curl": f'curl -X POST https://mcp.rugmunch.io/api/v1/x402-tools/audit \\n -H "Content-Type: application/json" \\n -H "X-RMI-Dev-Key: {result["key"]}" \\n -d \'{{"address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"}}\'', |
| "python": f"""from rmi import RMI |
| rmi = RMI(api_key="{result["key"]}") |
| result = rmi.scan("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") |
| """, |
| }, |
| }, |
| ) |
|
|
|
|
| @router.post("/verify") |
| async def verify_key(req: VerifyRequest): |
| """Verify an API key and get usage stats.""" |
| if not req.api_key: |
| return JSONResponse(status_code=400, content={"error": "api_key required"}) |
|
|
| hashed = hash_api_key(req.api_key) |
| usage = get_usage_for_key(hashed) |
|
|
| if "error" in usage: |
| return JSONResponse(status_code=401, content={"error": "Invalid API key"}) |
|
|
| return JSONResponse(content={"valid": True, **usage}) |
|
|
|
|
| @router.post("/usage") |
| async def get_usage(req: UsageRequest): |
| """Get detailed usage for an API key.""" |
| if not req.api_key: |
| return JSONResponse(status_code=400, content={"error": "api_key required"}) |
|
|
| hashed = hash_api_key(req.api_key) |
| r = get_redis() |
| if not r: |
| return JSONResponse(status_code=500, content={"error": "redis_unavailable"}) |
|
|
| |
| key_data = r.get(f"rmi:dev:key:{hashed}") |
| if not key_data: |
| return JSONResponse(status_code=401, content={"error": "Invalid API key"}) |
|
|
| |
| today = datetime.now(UTC) |
| daily_usage = [] |
| for i in range(7): |
| day = (today - timedelta(days=i)).strftime("%Y-%m-%d") |
| calls = int(r.get(f"rmi:dev:usage:{hashed}:{day}") or 0) |
| daily_usage.append({"date": day, "calls": calls}) |
|
|
| |
| tool_usage = {} |
| tool_keys = r.keys(f"rmi:dev:tool:{hashed}:*") |
| for tk in tool_keys[:50]: |
| tool_name = tk.decode().split(":")[-1] |
| count = int(r.get(tk) or 0) |
| if count > 0: |
| tool_usage[tool_name] = count |
|
|
| return JSONResponse( |
| content={ |
| "tier": json.loads(key_data).get("tier", "free"), |
| "daily_usage": daily_usage, |
| "tool_usage": dict(sorted(tool_usage.items(), key=lambda x: -x[1])[:20]), |
| } |
| ) |
|
|
|
|
| @router.get("/tiers") |
| async def list_tiers(): |
| """List available developer tiers.""" |
| return JSONResponse( |
| content={ |
| "tiers": { |
| name: { |
| "daily_limit": config["daily_limit"], |
| "rate_limit_per_min": config["rate_limit_per_min"], |
| "rate_limit_per_hour": config["rate_limit_per_hour"], |
| "price": config["price_usd"], |
| "webhook_support": config["webhook_support"], |
| "priority_queue": config["priority_queue"], |
| "analytics_access": config["analytics_access"], |
| } |
| for name, config in TIERS.items() |
| }, |
| "note": "FREE tier: 100 calls/day, no payment required. Upgrade to PAID for unlimited pay-per-use.", |
| } |
| ) |
|
|
|
|
| |
|
|
|
|
| class WalletVerifyRequest(BaseModel): |
| address: str |
| signature: str |
| chain: str = "evm" |
|
|
|
|
| @router.post("/wallet/verify") |
| async def verify_wallet_identity(req: WalletVerifyRequest): |
| """Verify wallet ownership via signature. |
| |
| Grants a persistent wallet-based identity for trials that survives IP changes. |
| Users sign a challenge message with their wallet to prove ownership. |
| """ |
| if not req.address or not req.signature: |
| return JSONResponse(status_code=400, content={"error": "address and signature required"}) |
|
|
| |
| challenge = f"RMI identity verification: {req.address} at {int(time.time()) // 3600}" |
|
|
| |
| if req.chain == "evm": |
| valid = verify_evm_signature(req.address, challenge, req.signature) |
| elif req.chain == "solana": |
| valid = verify_solana_signature(req.address, challenge, req.signature) |
| else: |
| return JSONResponse(status_code=400, content={"error": "chain must be 'evm' or 'solana'"}) |
|
|
| if not valid: |
| return JSONResponse(status_code=401, content={"error": "Invalid signature β wallet ownership not proven"}) |
|
|
| |
| r = get_redis() |
| if not r: |
| return JSONResponse(status_code=500, content={"error": "redis_unavailable"}) |
|
|
| wallet_id = f"wallet:{req.chain}:{req.address.lower()}" |
|
|
| |
| wallet_data = { |
| "address": req.address.lower(), |
| "chain": req.chain, |
| "verified_at": datetime.now(UTC).isoformat(), |
| "trial_multiplier": 3, |
| "daily_free_limit": 50, |
| "status": "active", |
| } |
|
|
| r.set(f"rmi:wallet:{wallet_id}", json.dumps(wallet_data), ex=86400 * 30) |
| r.incr("rmi:wallet:stats:total_verified") |
|
|
| return JSONResponse( |
| content={ |
| "verified": True, |
| "wallet_id": wallet_id, |
| "benefits": { |
| "trial_multiplier": 3, |
| "daily_free_limit": 50, |
| "persistent_identity": True, |
| }, |
| "message": "Wallet verified! You now get 3x trial limits and 50 free calls/day.", |
| } |
| ) |
|
|
|
|
| @router.post("/wallet/get_challenge") |
| async def get_wallet_challenge(req: VerifyRequest): |
| """Get a challenge message to sign with your wallet.""" |
| |
| if req.api_key: |
| hashed = hash_api_key(req.api_key) |
| key_data = get_redis().get(f"rmi:dev:key:{hashed}") if get_redis() else None |
| if key_data: |
| info = json.loads(key_data) |
| address = info.get("wallet_address", "") |
| if address: |
| challenge = f"RMI identity verification: {address} at {int(time.time()) // 3600}" |
| return JSONResponse( |
| content={ |
| "message": challenge, |
| "address": address, |
| "instruction": "Sign this message with your wallet to verify ownership.", |
| } |
| ) |
|
|
| |
| challenge = f"RMI identity verification at {int(time.time()) // 3600}" |
| return JSONResponse( |
| content={ |
| "message": challenge, |
| "instruction": "Sign this message with your wallet. Include your wallet address in the /wallet/verify call.", |
| } |
| ) |
|
|
|
|
| @router.get("/wallet/status/{chain}/{address}") |
| async def wallet_status(chain: str, address: str): |
| """Check wallet verification status and benefits.""" |
| r = get_redis() |
| if not r: |
| return JSONResponse(status_code=500, content={"error": "redis_unavailable"}) |
|
|
| wallet_id = f"rmi:wallet:wallet:{chain}:{address.lower()}" |
| data = r.get(wallet_id) |
| if not data: |
| return JSONResponse( |
| content={ |
| "verified": False, |
| "message": "Wallet not verified. Use POST /wallet/verify to verify.", |
| } |
| ) |
|
|
| wallet_data = json.loads(data) |
| return JSONResponse( |
| content={ |
| "verified": True, |
| **wallet_data, |
| } |
| ) |
|
|
|
|
| |
|
|
|
|
| async def check_developer_key(request: Request) -> dict[str, Any] | None: |
| """Check if request has a valid developer API key. |
| |
| Returns tier info if valid, None if not a dev key request. |
| This is called by the enforcement middleware BEFORE payment checks. |
| """ |
| dev_key = request.headers.get("X-RMI-Dev-Key", "") or request.headers.get("x-rmi-dev-key", "") |
| if not dev_key: |
| return None |
|
|
| hashed = hash_api_key(dev_key) |
| r = get_redis() |
| if not r: |
| return {"error": "redis_unavailable", "deny": True} |
|
|
| |
| key_data = r.get(f"rmi:dev:key:{hashed}") |
| if not key_data: |
| return {"error": "invalid_api_key", "deny": True, "status_code": 401} |
|
|
| key_info = json.loads(key_data) |
|
|
| |
| if key_info.get("status") != "active": |
| return {"error": "key_suspended", "deny": True, "status_code": 403} |
|
|
| |
| today = datetime.now(UTC).strftime("%Y-%m-%d") |
| today_calls = int(r.get(f"rmi:dev:usage:{hashed}:{today}") or 0) |
| tier_config = TIERS.get(key_info.get("tier", "free"), TIERS["free"]) |
|
|
| if today_calls >= tier_config["daily_limit"]: |
| return { |
| "error": "daily_limit_exceeded", |
| "deny": True, |
| "status_code": 429, |
| "daily_limit": tier_config["daily_limit"], |
| "calls_today": today_calls, |
| } |
|
|
| |
| minute_key = f"rmi:dev:ratelimit:{hashed}:{int(time.time()) // 60}" |
| minute_calls = int(r.get(minute_key) or 0) |
| if minute_calls >= tier_config["rate_limit_per_min"]: |
| return { |
| "error": "rate_limit_exceeded", |
| "deny": True, |
| "status_code": 429, |
| "rate_limit_per_min": tier_config["rate_limit_per_min"], |
| "retry_after": 60, |
| } |
|
|
| |
| pipe = r.pipeline() |
| pipe.incr(f"rmi:dev:usage:{hashed}:{today}") |
| pipe.incr(f"rmi:dev:usage:{hashed}:total") |
| pipe.incr(minute_key) |
| pipe.expire(minute_key, 120) |
| pipe.incr("rmi:dev:stats:total_calls") |
| pipe.execute() |
|
|
| return { |
| "valid": True, |
| "tier": key_info.get("tier", "free"), |
| "email": key_info.get("email", ""), |
| "remaining_today": tier_config["daily_limit"] - today_calls - 1, |
| "deny": False, |
| } |
|
|