""" RMI x402 Payment Enforcement Middleware ======================================== Intercepts /api/v1/x402-tools/* requests, verifies x402 payment headers. Returns 402 Payment Required when no valid payment is provided. ARCHITECTURE (May 23, 2026 — Multi-Facilitator): - Smart router auto-picks best facilitator per chain/token - 7 active facilitators: Coinbase CDP, PayAI, Cloudflare x402, AsterPay (EUR/SEPA), Primev (fee-free ETH), x402-rs (self-hosted), EIP-7702 (universal EVM) - 3 OFFLINE facilitators (NXDOMAIN): Pieverse, MERX TRON, Satoshi - 11 payment chains: Base, Solana, Ethereum, BSC, Arbitrum, Optimism, Polygon, Avalanche, Fantom, Gnosis, SEPA/EUR - TRON and Bitcoin chains disabled (sole facilitators dead) - Fallback: old PaymentVerifier if router unavailable Author: RMI Development Date: 2026-05-23 """ from __future__ import annotations import json import logging import os import re import time from typing import TYPE_CHECKING, Any if TYPE_CHECKING: import redis as _redis_types from fastapi import Request, Response from fastapi.responses import JSONResponse from app.core.redis import get_redis logger = logging.getLogger("x402_enforcement") # ── Payment address globals (initialized by _ensure_pay_addresses) ── EVM_PAY_TO: str | None = None SOL_PAY_TO: str | None = None # ── Idempotency helper ── def check_idempotency(key: str, ttl: int = 86400) -> bool: """Atomic SET NX check. Returns True if this is the first call with this key. Returns False if the key was already processed (duplicate).""" try: r = get_redis() if not r: return True return bool(r.set(key, "1", ex=ttl, nx=True)) except Exception: return True # Redis unavailable = can't dedup, allow through # ── Discovery endpoint cache ── _discovery_cache: dict | None = None _discovery_cache_time: float = 0 DISCOVERY_CACHE_TTL = 300 # 5 minutes # Security headers for all x402 responses SECURITY_HEADERS = { "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", # X-XSS-Protection intentionally omitted — deprecated by all modern browsers "Cache-Control": "no-store", } # ── Import existing PaymentVerifier (LAZY — deferred to first use) ── VERIFIER = None _FACILITATOR_CONFIGS = None _TOKEN_METADATA = None _verifier_loaded = False def _ensure_verifier(): global VERIFIER, _FACILITATOR_CONFIGS, _TOKEN_METADATA, _verifier_loaded if _verifier_loaded: return _verifier_loaded = True try: from app.routers.x402_middleware import FACILITATOR_CONFIGS, TOKEN_METADATA, PaymentVerifier VERIFIER = PaymentVerifier() _FACILITATOR_CONFIGS = FACILITATOR_CONFIGS _TOKEN_METADATA = TOKEN_METADATA logger.info("x402 enforcement: using existing PaymentVerifier") except ImportError as e: VERIFIER = None logger.warning(f"x402 enforcement: PaymentVerifier not available: {e}") def get_redis_async() -> "aioredis.Redis | None": """Get async Redis client. Use in async middleware paths.""" try: from app.core.redis import get_redis_async as _get_async return _get_async() except Exception: return None # ── Multi-chain USDC configs ── # PAYMENT chains: Base and Solana use facilitators (fast, federated verification). # All other EVM chains use self-verification via Etherscan/Alchemy on-chain checks. # Same EVM wallet works across all chains — user pays on whichever has USDC. # This is a competitive advantage: most x402 gateways only take Base. CHAIN_USDC = { # ── Facilitator-verified chains (instant/direct) ── "base": { "network": "eip155:8453", "chain_id": 8453, "usdc": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "name": "USD Coin", "version": "2", "method": "local_eip712", "verify": "facilitator", "facilitators": ["coinbase_cdp", "payai"], }, "solana": { "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id": None, "usdc": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "name": "USD Coin", "version": "2", "method": "payai", "verify": "facilitator", "facilitators": ["payai"], }, "bsc": { "network": "eip155:56", "chain_id": 56, "usdc": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", "name": "USD Coin", "version": "2", "method": "local_eip712", "verify": "facilitator", "facilitators": ["eip7702"], }, # pieverse REMOVED — OFFLINE (api.pieverse.xyz NXDOMAIN) "ethereum": { "network": "eip155:1", "chain_id": 1, "usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "name": "USD Coin", "version": "2", "method": "local_eip712", "verify": "facilitator", "facilitators": ["primev", "payai", "cloudflare_x402", "eip7702"], }, "tron": { "network": "tron:mainnet", "chain_id": None, "usdc": "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8", "name": "USD Coin (TRC20)", "version": "2", "method": "tron_selfverify", "verify": "facilitator", "facilitators": ["tron_selfverify"], "tokens": { "USDT": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "USDD": "TPYmHEhy5n8TCEfZGqW2rPbmgh1fGqNBPa", }, }, "bitcoin": { "network": "bitcoin:mainnet", "chain_id": None, "usdc": "", "name": "Bitcoin", "version": "2", "method": "bitcoin_selfverify", "verify": "facilitator", "facilitators": ["bitcoin_selfverify"], "tokens": {"BTC": "native"}, }, # ── Self-verified EVM chains (EIP-7702 universal) ── "arbitrum": { "network": "eip155:42161", "chain_id": 42161, "usdc": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", "name": "USD Coin", "version": "2", "method": "local_eip712", "verify": "self", "facilitators": ["eip7702"], }, "optimism": { "network": "eip155:10", "chain_id": 10, "usdc": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", "name": "USD Coin", "version": "2", "method": "local_eip712", "verify": "self", "facilitators": ["eip7702"], }, "polygon": { "network": "eip155:137", "chain_id": 137, "usdc": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", "name": "USD Coin", "version": "2", "method": "local_eip712", "verify": "self", "facilitators": ["eip7702"], }, "avalanche": { "network": "eip155:43114", "chain_id": 43114, "usdc": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", "name": "USD Coin", "version": "2", "method": "local_eip712", "verify": "self", "facilitators": ["eip7702"], }, "fantom": { "network": "eip155:250", "chain_id": 250, "usdc": "0x04068DA6C83AFCFA0e13ba15A6696662335D5B75", "name": "USD Coin", "version": "2", "method": "local_eip712", "verify": "self", "facilitators": ["eip7702"], }, "gnosis": { "network": "eip155:100", "chain_id": 100, "usdc": "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83", "name": "USD Coin", "version": "2", "method": "local_eip712", "verify": "self", "facilitators": ["eip7702"], }, # ── SEPA/EUR (AsterPay fiat off-ramp) ── "sepa": { "network": "sepa:eur", "chain_id": None, "usdc": "", "name": "Euro", "version": "1", "method": "asterpay", "verify": "facilitator", "facilitators": ["asterpay"], "tokens": {"EUR": "fiat"}, }, } # Pay-to addresses — pulled dynamically from WalletManagerV2 # Fallback: env vars or legacy hardcoded addresses def _get_pay_to_address(chain: str) -> str: """Get active x402 payment address from WalletManagerV2, with fallbacks.""" try: from app.wallet_manager_v2 import get_wallet_manager_v2 mgr = get_wallet_manager_v2(os.getenv("WALLET_VAULT_PASSWORD", "")) for w in mgr._wallets.values(): if w.chain == chain and w.x402_enabled and w.status == "active": return w.address except Exception: pass # Fallback: env vars if chain == "eth": return os.getenv("X402_EVM_PAY_TO", "0x1E3AC01d0fdb976179790BDD02823196A92705C9") if chain == "sol": return os.getenv("X402_SOL_PAY_TO", "Gix4P9AmwcZRGzr2hCEME5m2QAvY86dBfm8c7e7MpFzv") # Try to find any EVM wallet for EVM chains if chain in ("base", "polygon", "arbitrum", "optimism", "avalanche", "bsc", "fantom", "gnosis"): return _get_pay_to_address("eth") return "" # Deferred — wallet_manager_v2 pulls bip_utils+monero (~450ms) # ── Tool pricing (parsed from gateway configs) ── TOOL_PRICES: dict[str, dict[str, Any]] = {} def _load_tool_prices(): """Load tool prices from canonical dict (single source of truth). 1. Load from CANONICAL_TOOL_PRICES (127 tools, always present) 2. Enrich from gateway configs if available (adds chain variants) 3. NO circular catalog API fallback — that was the bug """ import os import re # ─── 1. Load canonical base (ALWAYS works, no circular dependency) ──── from app.canonical_tools import CANONICAL_TOOL_PRICES TOOL_PRICES.update(CANONICAL_TOOL_PRICES) # ─── 2. Enrich from gateway configs (adds chain-specific variants) ──────── # Try multiple paths: Docker mount, host path, OpenClaw backup gateway_base = None for candidate in [ "/app/x402-gateway", "/root/backend/x402-gateway", "/srv/rmi/backend/x402-gateway", "/root/.openclaw/backend/x402-gateway", ]: if os.path.isdir(candidate): gateway_base = candidate break if gateway_base and os.path.exists(gateway_base): logger.info(f"Loading tool prices from gateway configs at {gateway_base}") for chain_dir in os.listdir(gateway_base): index_path = os.path.join(gateway_base, chain_dir, "index.ts") if not os.path.exists(index_path): continue with open(index_path) as f: content = f.read() # Flexible regex to extract key-value pairs from tool definitions tool_pattern = r"(\w+):\s*\{([^}]+)\}" for m in re.finditer(tool_pattern, content): tool_id = m.group(1) body = m.group(2) if "name:" not in body or "price:" not in body: continue # Extract individual fields name_match = re.search(r'name:\s*"([^"]+)"', body) price_match = re.search(r'price:\s*"\$([^"]+)"', body) atoms_match = re.search(r'priceAtomic:\s*"([^"]+)"', body) cat_match = re.search(r'category:\s*"([^"]+)"', body) trial_match = re.search(r"trialFree:\s*(\d+)", body) if all([name_match, price_match, atoms_match, cat_match, trial_match]): TOOL_PRICES[tool_id] = { "price_usd": float(price_match.group(1)), # type: ignore[union-attr] "price_atoms": atoms_match.group(1), # type: ignore[union-attr] "category": (cat_match.group(1) or "").lower(), # type: ignore[union-attr] "trial_free": int(trial_match.group(1)), # type: ignore[union-attr] "description": name_match.group(1) or "", # type: ignore[union-attr] } # ─── 2. Add manual pricing for new tools ──────────────────────────────── _NEW_TOOL_PRICES = { "forensic_valuation": { "price_usd": 0.25, "price_atoms": "250000", "category": "premium", "trial_free": 1, "description": "Institutional-grade token valuation — DCF intrinsic value, comparable analysis with outlier detection, scam probability scoring", }, "osint_identity_hunt": { "price_usd": 0.15, "price_atoms": "150000", "category": "premium", "trial_free": 2, "description": "Cross-platform OSINT investigation — hunt usernames across 400+ networks, domain intelligence, stealth page capture", }, "investigation_report": { "price_usd": 0.20, "price_atoms": "200000", "category": "premium", "trial_free": 1, "description": "Full investigation report — on-chain forensics, financial valuation, OSINT findings, scam scoring in one deliverable", }, "whale_accumulation": { "price_usd": 0.10, "price_atoms": "100000", "category": "intelligence", "trial_free": 2, "description": "Accumulation Pattern Detector — detect stealth accumulation by large holders before price impact. Identifies quiet buying patterns and wallet funding sequences.", }, "social_engineering": { "price_usd": 0.15, "price_atoms": "150000", "category": "premium", "trial_free": 1, "description": "Social Engineering & Identity Fraud Detector — detect fake teams, AI-generated profiles, phishing domains, copied whitepapers, and social engineering campaigns.", }, "forensic_pack": { "price_usd": 0.35, "price_atoms": "350000", "category": "bundle", "trial_free": 1, "description": "Forensic Investigation Pack — valuation + OSINT + report at 33% discount", }, "token_watch_create": { "price_usd": 0.05, "price_atoms": "50000", "category": "monitoring", "trial_free": 3, "description": "Set token monitoring watch — alerts when LP drops, price changes, or rug indicators detected", }, "token_watch_check": { "price_usd": 0.03, "price_atoms": "30000", "category": "monitoring", "trial_free": 5, "description": "One-shot token status check — current LP, price, volume, and rug risk warnings", }, } TOOL_PRICES.update(_NEW_TOOL_PRICES) # ─── 3. NO MORE circular catalog API fallback ───────────────────────── # Removed: was causing circular dependency (catalog reads from TOOL_PRICES, # which loads from catalog). Canonical_tools.py is now the source of truth. # ── Add new tools from x402_tools.py that aren't in gateway configs yet ── try: _NEW_TOOL_PRICES = { "forensic_valuation": { "price_usd": 0.25, "price_atoms": "250000", "category": "premium", "trial_free": 1, "description": "Institutional-grade token valuation — DCF intrinsic value, comparable analysis with outlier detection, scam probability scoring", }, "osint_identity_hunt": { "price_usd": 0.15, "price_atoms": "150000", "category": "premium", "trial_free": 2, "description": "Cross-platform OSINT investigation — hunt usernames across 400+ networks, domain intelligence, stealth page capture", }, # API / meta tools — must be in TOOL_PRICES at startup (not loaded from catalog) "catalog": { "price_usd": 0.00, "price_atoms": "0", "category": "api", "trial_free": 999, "description": "Browse available tools, pricing, and chain support", }, "smart_money_alpha": { "price_usd": 0.01, "price_atoms": "10000", "category": "intelligence", "trial_free": 3, "description": "Smart money alpha signals — track wallets that consistently outperform the market", }, "meme_vibe_score": { "price_usd": 0.01, "price_atoms": "10000", "category": "social", "trial_free": 3, "description": "Meme token vibe scoring — sentiment, community strength, and virality analysis", }, "mcp-proxy": { "price_usd": 0.01, "price_atoms": "10000", "category": "api", "trial_free": 5, "description": "MCP protocol proxy — route tool calls through the x402 payment layer", }, "human-execute": { "price_usd": 0.02, "price_atoms": "20000", "category": "api", "trial_free": 2, "description": "Human-in-the-loop execution — wallet-based payment for manual crypto investigation tasks", }, # Premium standout tools "reputation_score": { "price_usd": 0.10, "price_atoms": "100000", "category": "premium", "trial_free": 1, "description": "Comprehensive 0-100 trust score combining wallet labels, scam databases, deployer history, and RAG similarity matching", }, "webhook_register": { "price_usd": 0.02, "price_atoms": "20000", "category": "monitoring", "trial_free": 2, "description": "Register webhook URL for real-time monitoring alerts — rug pulls, whale moves, price crashes", }, "webhook_list": { "price_usd": 0.00, "price_atoms": "0", "category": "monitoring", "trial_free": 999, "description": "List registered webhooks for an address", }, # Advanced standout tools "rug_probability": { "price_usd": 0.15, "price_atoms": "150000", "category": "premium", "trial_free": 1, "description": "Predictive rug pull probability 0-100 — honeypot + liquidity + deployer + social signals", }, "history": { "price_usd": 0.08, "price_atoms": "80000", "category": "analysis", "trial_free": 2, "description": "Historical scanner time-series — risk/liquidity/volume/price trends over hours", }, "narrative": { "price_usd": 0.05, "price_atoms": "50000", "category": "social", "trial_free": 3, "description": "Market narrative engine — what is the market saying about this token RIGHT NOW", }, # Institutional tools "portfolio_risk": { "price_usd": 0.20, "price_atoms": "200000", "category": "premium", "trial_free": 1, "description": "Cross-chain portfolio risk dashboard — unified risk across multiple wallets and chains", }, "defi_position": { "price_usd": 0.15, "price_atoms": "150000", "category": "defi", "trial_free": 1, "description": "DeFi position analyzer — LP holdings, impermanent loss estimation, yield sustainability, protocol risk", }, "liquidation_cascade": { "price_usd": 0.15, "price_atoms": "150000", "category": "defi", "trial_free": 1, "description": "Liquidation Cascade Risk Analyzer — cross-chain DeFi position monitoring, health factor computation, and cascade scenario simulation for Aave/Compound positions", }, "mev_detect": { "price_usd": 0.15, "price_atoms": "150000", "category": "security", "trial_free": 2, "description": "MEV/Sandwich attack detection — sandwich attacks, frontrunning, arbitrage extraction, MEV bot identification", }, # Alpha tools "composite_score": { "price_usd": 0.25, "price_atoms": "250000", "category": "premium", "trial_free": 1, "description": "RMI Composite Score — one number combining ALL signals for instant buy/sell/avoid decisions", }, "smart_money": { "price_usd": 0.20, "price_atoms": "200000", "category": "intelligence", "trial_free": 1, "description": "Smart Money P&L Tracker — real profitability-based wallet tracking, find the actual profitable traders", }, "clone_detect": { "price_usd": 0.10, "price_atoms": "100000", "category": "security", "trial_free": 2, "description": "Token Clone Detector — find tokens cloned from known rug pulls by name, symbol, and deployer patterns", }, "wash_trade_detect": { "price_usd": 0.15, "price_atoms": "150000", "category": "security", "trial_free": 2, "description": "Wash Trading & Insider Detection — artificial volume, coordinated buying, insider accumulation patterns", }, # RIP: Rug Pull Imminence Predictor "rug_imminence": { "price_usd": 0.20, "price_atoms": "200000", "category": "security", "trial_free": 1, "description": "Rug Pull Imminence Predictor — AI-powered early warning system fusing 7 signals to predict imminent rug pulls before they happen", }, "cross_chain_whale": { "price_usd": 0.08, "price_atoms": "80000", "category": "intelligence", "trial_free": 2, "description": "Track whale wallets across multiple blockchains simultaneously — maps cross-chain capital flows, bridge migrations, and multi-network positioning of top holders", }, } TOOL_PRICES.update(_NEW_TOOL_PRICES) except Exception as e: logger.warning(f"x402 enforcement: could not add new tool prices: {e}") # ── TOOL_PRICES lazy loading (deferred — saves ~1.5s cold start) ── _tool_prices_extras_loaded = False def _ensure_tool_prices(): """Load expanded tools, bundles, and DataBus pricing on first use.""" global _tool_prices_extras_loaded if _tool_prices_extras_loaded: return _tool_prices_extras_loaded = True # Expanded tools: 44 new specialized tools + 80 per-chain variants try: from app.routers._expanded_tools import ADDITIONAL_TOOLS as _EXPANDED_TOOLS TOOL_PRICES.update(_EXPANDED_TOOLS) logger.info(f"x402 enforcement: loaded {len(_EXPANDED_TOOLS)} expanded tools") except Exception as e: logger.warning(f"x402 enforcement: could not load expanded tools: {e}") # x402_tools.py BUNDLES dict as fallback try: from app.routers.x402_tools import BUNDLES as _TOOLS_BUNDLES for bid, b in _TOOLS_BUNDLES.items(): if bid not in TOOL_PRICES and b.get("category") == "bundle": TOOL_PRICES[bid] = { "price_usd": b.get("bundle_price_usd", 0.01), "price_atoms": b.get("bundle_price_atoms", "10000"), "category": "bundle", "trial_free": b.get("trial_free", 1), "description": b.get("name", bid), } except Exception as e: logger.warning(f"x402 enforcement: could not load x402_tools bundles: {e}") # DataBus-powered tools try: from app.routers.x402_databus_tools import X402_TOOL_PRICING as _DATABUS_PRICING TOOL_PRICES.update(_DATABUS_PRICING) logger.info(f"x402 enforcement: loaded {len(_DATABUS_PRICING)} DataBus-powered tools") except Exception as e: logger.warning(f"x402 enforcement: could not load DataBus tools: {e}") # Clean up corrupted tool IDs _bad_keys = [k for k in TOOL_PRICES if "{" in k or "/" in k or " " in k] if _bad_keys: logger.warning(f"Removing corrupted tool IDs: {_bad_keys}") for _k in _bad_keys: del TOOL_PRICES[_k] # ── Tool prices loaded lazily via _ensure_tool_prices() — no module-level side effects # ── Chain display names (used in human-readable messages) ── CHAIN_NAMES = { "base": "Base", "solana": "Solana", "ethereum": "Ethereum", "bsc": "BNB Chain", "tron": "TRON", "bitcoin": "Bitcoin", "arbitrum": "Arbitrum", "optimism": "Optimism", "polygon": "Polygon", "avalanche": "Avalanche", "fantom": "Fantom", "gnosis": "Gnosis", "sepa": "SEPA (EUR)", } # ── x402 v2 spec helpers ── import base64 as _base64 def _ensure_pay_addresses(): """Ensure EVM_PAY_TO and SOL_PAY_TO are populated from env vars.""" global EVM_PAY_TO, SOL_PAY_TO if not EVM_PAY_TO: EVM_PAY_TO = os.getenv("X402_PAYMENT_ADDRESS_EVM", os.getenv("X402_PAYMENT_ADDRESS", "")) if not SOL_PAY_TO: SOL_PAY_TO = os.getenv("X402_PAYMENT_ADDRESS_SOL", os.getenv("X402_PAYMENT_ADDRESS", "")) def _build_accepts_list(tool_id: str, pricing: dict) -> list: """Build x402 v2 spec 'accepts' array from TOOL_PRICES and CHAIN_USDC. This produces the canonical PaymentRequirements format that official x402 SDKs (@x402/fetch, x402 Python, x402 MCP) parse via PAYMENT-REQUIRED header or response body. """ accepts = [] for chain_key, cfg in CHAIN_USDC.items(): method = cfg["method"] # Determine pay-to address based on chain if method == "payai": pay_to = SOL_PAY_TO elif method == "bitcoin_selfverify": pay_to = os.getenv("X402_BTC_PAY_TO", "") elif method == "tron_selfverify": pay_to = os.getenv("X402_TRON_PAY_TO", "") elif method == "asterpay": pay_to = os.getenv("ASTERPAY_SEPA_IBAN", "") else: pay_to = EVM_PAY_TO # Determine asset (primary token for the chain) asset = cfg.get("usdc", "") if not asset and "tokens" in cfg: first_token = next(iter(cfg["tokens"].values()), "") asset = first_token if first_token != "native" else "" extra = { "name": cfg["name"], "version": cfg["version"], "tool": tool_id, "chain": chain_key, } if method == "local_eip712" and cfg.get("chain_id"): extra["domain"] = { "name": cfg["name"], "version": cfg["version"], "chainId": cfg["chain_id"], "verifyingContract": pay_to, } elif method == "payai": extra["feePayer"] = "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4" elif method == "tron_selfverify": extra["tronNetwork"] = "mainnet" extra["trc20Tokens"] = cfg.get("tokens", {}) elif method == "bitcoin_selfverify": extra["paymentNetwork"] = "bitcoin" extra["settlementChains"] = ["base", "solana"] elif method == "asterpay": extra["currency"] = "EUR" extra["sepa"] = True requirement = { "scheme": "exact", "network": cfg["network"], "asset": asset, "amount": pricing["price_atoms"], "payTo": pay_to, "maxTimeoutSeconds": 180, "extra": extra, } # Add supported tokens for multi-token chains if "tokens" in cfg: requirement["supportedTokens"] = list(cfg["tokens"].keys()) accepts.append(requirement) return accepts def _build_bazaar_extension(tool_id: str, pricing: dict, accepts: list) -> dict: """Build x402 v2 bazaar extension for facilitator discovery indexing. Per x402 v2 bazaar extension spec, the extension follows the standard v2 pattern: {info: {...}, schema: {...}}. - info: Contains the actual discovery data (HTTP method, parameters, output format) - schema: JSON Schema (Draft 2020-12) that validates the structure of info Per the bazaar spec: - input.type: always "http" - input.method: HTTP method (GET for tool_id ending in _info/_list, POST for others) - output.type: always "json" """ # Map our categories to bazaar-standard categories CATEGORY_MAP = { "security": "security", "intelligence": "intelligence", "market": "market-data", "analysis": "analytics", "social": "social", "launchpad": "launchpad", "bundle": "bundles", "defi": "defi", "premium": "premium", "api": "api", "variant": "crypto", } category = pricing.get("category", "analysis") bazaar_category = CATEGORY_MAP.get(category, category) # Determine HTTP method: GET for read-only queries, POST for tools that execute read_only_suffixes = ("_info", "_list", "_check", "_scan", "_status", "_health") is_read_only = tool_id.endswith(read_only_suffixes) http_method = "GET" if is_read_only else "POST" # Build input/output from the first accepted payment method first_accept = accepts[0] if accepts else {} first_accept.get("extra", {}) # Build inputSchema matching the bazaar spec's info structure info_input: dict[str, Any] = { "type": "http", "method": http_method, } if http_method == "GET": # Query params for GET: address and optional chain info_input["queryParams"] = { "address": { "type": "string", "description": f"Blockchain address or identifier for {tool_id}", }, "chain": {"type": "string", "description": "Blockchain to query (default: base)"}, } else: # Body params for POST: address and chain info_input["bodyType"] = "json" info_input["body"] = { "address": { "type": "string", "description": f"Blockchain address or identifier for {tool_id}", }, "chain": {"type": "string", "description": "Blockchain to query (default: base)"}, } info = { "input": info_input, "output": { "type": "json", "example": { "data": {"result": "tool output"}, "sources_used": ["source1", "source2"], }, }, "category": bazaar_category, "tags": ["crypto", "security", "blockchain", tool_id], "description": pricing.get("description", f"{tool_id} — Rug Munch Intelligence"), } # Schema that validates the info structure per bazaar spec schema = { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "input": { "type": "object", "properties": { "type": {"type": "string", "const": "http"}, "method": { "type": "string", "enum": ["GET", "HEAD", "DELETE"] if http_method == "GET" else ["POST", "PUT", "PATCH"], }, "queryParams": {"type": "object"} if http_method == "GET" else {"type": "object"}, "bodyType": {"type": "string", "enum": ["json", "form-data", "text"]}, "body": {"type": "object"}, }, "required": ["type", "method"], "additionalProperties": False, }, "output": { "type": "object", "properties": { "type": {"type": "string"}, "example": {"type": "object"}, }, "required": ["type"], }, "category": {"type": "string"}, "tags": {"type": "array", "items": {"type": "string"}}, "description": {"type": "string"}, }, "required": ["input"], } return { "info": info, "schema": schema, } def _build_resource_info(tool_id: str, pricing: dict) -> dict: """Build x402 v2 ResourceInfo object for PaymentRequired response. Per x402 v2 spec section 5.1.2, ResourceInfo has only three fields: - url (required): URL of the protected resource - description (optional): Human-readable description - mimeType (optional): MIME type of the expected response NOTE: serviceName, tags, and iconUrl are NOT part of the spec's ResourceInfo. The v2 spec does NOT include serviceName/tags/iconUrl at the resource level. We preserve them as legacy compat fields in x402.resource_metadata for our own internal use, but they must not appear in the spec-compliant ResourceInfo. """ description = pricing.get("description", f"Rug Munch Intelligence — {tool_id}") # Per spec: max 200 chars for description if len(description) > 200: description = description[:197] + "..." return { "url": f"https://mcp.rugmunch.io/api/v1/x402-tools/{tool_id}", "description": description, "mimeType": "application/json", } # ── Trial management ── def check_trial(tool_id: str, client_id: str, max_trials: int = 3) -> tuple[bool, int]: """Check if a client has remaining trial calls for a tool. Uses Redis with TTL-based counters. Each client gets `max_trials` free calls per tool. The counter resets after TRIAL_WINDOW_SECONDS. Returns (can_use, remaining) tuple. """ try: r = get_redis() if not r: return True, max_trials key = f"x402:trial:{client_id}:{tool_id}" used_raw = r.get(key) used = int(used_raw) if used_raw else 0 if used >= max_trials: ttl = r.ttl(key) remaining = 0 if ttl > 0 else max_trials return False, remaining return True, max_trials - used except Exception as e: logger.error(f"Trial check failed: {e}") return True, max_trials def consume_trial(tool_id: str, client_id: str, max_trials: int = 3) -> bool: """Record a trial usage. Increments counter, sets TTL on first use.""" try: r = get_redis() if not r: return False key = f"x402:trial:{client_id}:{tool_id}" used_raw = r.get(key) if used_raw is None: r.setex(key, 86400, "1") else: r.incr(key) return True except Exception as e: logger.error(f"Trial consume failed: {e}") return False # ── 402 response builder ── def build_402_response(tool_id: str, client_id: str = "") -> JSONResponse: """Build an x402 v2 compliant Payment Required response. Returns both: - V2 spec format (x402Version, resource, accepts) for official SDK compatibility - Legacy format (error, tool, price, x402.requirements) for backward compat - PAYMENT-REQUIRED base64 header for @x402/fetch and x402 Python SDK The v2 spec requires: - Top-level x402Version: 2 - Top-level resource: {url, description, mimeType, serviceName, tags, iconUrl} - Top-level accepts: [{scheme, network, asset, amount, payTo, maxTimeoutSeconds, extra}] - PAYMENT-REQUIRED header: base64 JSON of {x402Version, resource, accepts} - Optional extensions: {bazaar: {discoverable, category, tags, inputSchema, outputSchema}} """ pricing = TOOL_PRICES.get(tool_id, {"price_usd": 0.01, "price_atoms": "10000", "trial_free": 3}) trial_free = pricing.get("trial_free", 3) # Check actual remaining trials for this client remaining = 0 if client_id: _can_use, remaining = check_trial(tool_id, client_id) # Build v2 spec accepts array (shared by both body and header) accepts = _build_accepts_list(tool_id, pricing) # Build v2 spec resource object resource = _build_resource_info(tool_id, pricing) # Build bazaar extension for discovery indexing bazaar = _build_bazaar_extension(tool_id, pricing, accepts) # ── V2 spec body format (what official SDKs parse) ── v2_body = { "x402Version": 2, "error": "PAYMENT-SIGNATURE header is required", "resource": resource, "accepts": accepts, "extensions": { "bazaar": bazaar, }, # ── Legacy compat fields (not in spec but needed for our gateway clients) ── "error_legacy": "Payment Required", "tool": tool_id, "price": f"${pricing['price_usd']:.2f}", "trial_free": trial_free, "trial_remaining": remaining, "trial_used": remaining <= 0, # -1 = wallet required (no client_id provided, cannot check trials) "wallet_required": remaining < 0, "message": ( f"Connect a wallet to continue. Your 1 free trial is used — link MetaMask or Phantom to get {trial_free} free calls per tool. From ${pricing['price_usd']:.2f}/call after that." if remaining < 0 else f"All {trial_free} free trial{'s' if trial_free != 1 else ''} used. Pay {pricing['price_atoms']} atoms to use {tool_id}. Pay on {', '.join(CHAIN_NAMES.get(c, c) for c in CHAIN_USDC)}." ), "accepted_chains": list(CHAIN_USDC.keys()), "chain_details": { k: { "network": v["network"], "facilitators": v.get("facilitators", []), "tokens": list(v.get("tokens", {"USDC": v.get("usdc", "")}).keys()), } for k, v in CHAIN_USDC.items() }, # ── Legacy compat: nested requirements for our Cloudflare workers ── "x402": { "version": "2", "requirements": accepts, # Same array, different field name for backward compat }, } # ── Build PAYMENT-REQUIRED header (base64 JSON of v2 spec PaymentRequired) ── payment_required_header_obj = { "x402Version": 2, "error": "PAYMENT-SIGNATURE header is required", "resource": resource, "accepts": accepts, "extensions": {"bazaar": bazaar}, } payment_required_b64 = _base64.b64encode( json.dumps(payment_required_header_obj, separators=(",", ":")).encode() ).decode("ascii") return JSONResponse( status_code=402, content=v2_body, headers={ "PAYMENT-REQUIRED": payment_required_b64, "X-Paywall-Version": "2", "Content-Type": "application/json", **SECURITY_HEADERS, }, ) # ── Payment header parser (supports v1 x-pay and v2 PAYMENT-SIGNATURE) ── def parse_x_pay_header(header_value: str) -> dict | None: """Parse payment payload from x-pay / PAYMENT-SIGNATURE header. Supports three formats: 1. v2 spec: base64-encoded JSON (PAYMENT-SIGNATURE header) 2. v1 legacy: "x402 " or "X-Pay: " (x-pay header) 3. Plain JSON """ if not header_value or not header_value.strip(): return None try: stripped = header_value.strip() # v2 spec: PAYMENT-SIGNATURE is base64-encoded JSON # Try base64 decode first (will fail fast if it's not base64) try: decoded = _base64.b64decode(stripped).decode("utf-8") if decoded.startswith("{"): payload = json.loads(decoded) # v2 format: normalize PaymentPayload to our expected structure # v2 PaymentPayload has: x402Version, resource, accepted, payload, extensions # Our verifier expects: accepted (dict), payload (with signature/authorization) if "x402Version" in payload: # v2 PaymentPayload — our verifier already handles this format # It extracts network from payload.accepted and routes to facilitator return payload return payload except (MemoryError, KeyboardInterrupt, SystemExit): raise # Never catch these except Exception: pass # Not base64, try other formats # v1 legacy: "x402 " or "X-Pay: " if stripped.startswith("x402 "): payload_str = stripped[5:].strip() elif stripped.startswith("X-Pay: "): payload_str = stripped[7:].strip() else: payload_str = stripped if payload_str.startswith("{"): return json.loads(payload_str) return None except Exception as e: logger.warning(f"Failed to parse payment header: {e}") return None # ── Verification via Facilitator Router ── async def verify_payment_via_router(payload: dict) -> dict: """Verify x402 payment payload through the multi-facilitator smart router. Flow: 1. Parse payload → extract network (chain) and asset (token) 2. Map network to chain_key (e.g. 'eip155:8453' → 'base', 'tron:mainnet' → 'tron') 3. Determine token symbol from asset address 4. Route to best facilitator via FacilitatorRouter.verify() 5. Fall back to old verify_payment() if router not available """ accepted = payload.get("accepted", {}) network = accepted.get("network", "") asset_address = accepted.get("asset", "") # Map network → chain_key chain_key = None token_symbol = "USDC" for ck, cfg in CHAIN_USDC.items(): if cfg["network"] == network: chain_key = ck # Detect token from asset address if asset_address: token_symbol = _detect_token_from_asset(asset_address, cfg) break if not chain_key: # Unknown network — fall back to old verifier try: from app.routers.x402_middleware import PaymentVerifier verifier = PaymentVerifier() return await verifier.verify_payment( json.dumps(payload), network_key="base", ) except ImportError as e: logger.error(f"Fallback verifier import failed: {e}") return {"verified": False, "reason": "No verifier available for unknown network"} # Try the smart router first try: from app.facilitators.router import get_facilitator_router router = get_facilitator_router() # Build requirements from payload requirements = { "x402Version": payload.get("x402Version", 2), "resource": payload.get("resource", {}), "accepts": [ { "scheme": accepted.get("scheme", "exact"), "network": network, "asset": asset_address, "amount": accepted.get("amount", ""), "payTo": accepted.get("payTo", ""), "maxTimeoutSeconds": accepted.get("maxTimeoutSeconds", 180), "extra": accepted.get("extra", {}), } ], } result = await router.verify( payload=payload, chain_key=chain_key, token_symbol=token_symbol, requirements=requirements, ) if result.verified: logger.info( f"Router verified payment via {result.facilitator}: " f"chain={chain_key} token={token_symbol} amount={result.amount}" ) return { "verified": True, "reason": result.reason, "tx_hash": result.tx_hash, "payer": result.payer, "amount": result.amount, "chain": chain_key, "token": token_symbol, "facilitator": result.facilitator, "method": f"router:{result.facilitator}", } else: logger.warning( f"Router rejected payment for {chain_key}/{token_symbol}: {result.reason}" ) return { "verified": False, "reason": result.reason, "chain": chain_key, "token": token_symbol, } except ImportError: logger.debug("Facilitator router not available — falling back to old verifier") except Exception as e: logger.error(f"Router verification error: {e} — falling back to old verifier") # Fallback: old verification logic return await verify_payment(payload) def _detect_token_from_asset(asset_address: str, chain_cfg: dict) -> str: """Detect token symbol from asset address using chain config.""" asset_lower = asset_address.lower() # Check USDC if chain_cfg.get("usdc", "").lower() == asset_lower: return "USDC" # Check additional tokens tokens = chain_cfg.get("tokens", {}) for symbol, addr in tokens.items(): if isinstance(addr, str) and addr.lower() == asset_lower: return symbol # Heuristic detection if "TR7NH" in asset_lower: return "USDT" # USDT on TRC20 if "TEkxi" in asset_lower: return "USDC" # USDC on TRC20 if "TPYm" in asset_lower: return "USDD" # USDD on TRC20 if asset_lower == "native" or asset_address == "BTC": return "BTC" return "USDC" # Default # ── Original verification logic (fallback) ── async def verify_payment(payload: dict) -> dict: """Verify x402 payment payload against all supported chains""" if not VERIFIER: return {"verified": False, "reason": "PaymentVerifier not initialized"} accepted = payload.get("accepted", {}) network = accepted.get("network", "") # Find matching chain config chain_key = None chain_cfg = None for ck, cc in CHAIN_USDC.items(): if cc["network"] == network: chain_key = ck chain_cfg = cc break if not chain_cfg: return {"verified": False, "reason": f"Unsupported network: {network}"} verify_method = chain_cfg.get("verify", "facilitator") # Facilitator-verified chains (Base, Solana) — fast, federated if verify_method == "facilitator": if chain_cfg["method"] == "local_eip712": return VERIFIER._verify_eip712_local(payload) elif chain_cfg["method"] == "payai": return await VERIFIER._verify_via_payai(payload, None) # Self-verified chains (ETH, BSC, ARB, OPT, POL) — check USDC transfer on-chain if verify_method == "self": return await self_verify_evm_usdc(payload, chain_key or "", chain_cfg) return {"verified": False, "reason": f"Unknown verify method: {verify_method}"} async def self_verify_evm_usdc(payload: dict, chain_key: str, chain_cfg: dict) -> dict: """Self-verify a USDC transfer on EVM chains without a facilitator. _ensure_pay_addresses() How it works: 1. Extract tx hash, network, sender from the x402 payload 2. Look up the transaction receipt on Etherscan/BSCScan/etc via eth_getTransactionReceipt 3. Decode ERC-20 Transfer events from receipt logs (topic0 = keccak256("Transfer(address,address,uint256)")) 4. Verify: correct USDC contract, 'to' matches PAY_TO, amount matches expected price in atoms 5. Mark the tx as spent in Redis with 86400s TTL (prevent double-use, 24h window) """ # ERC-20 Transfer event signature: keccak256("Transfer(address,address,uint256)") TRANSFER_EVENT_TOPIC0 = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" try: accepted = payload.get("accepted", {}) tx_hash = payload.get("txHash") or payload.get("signature") or accepted.get("transaction") payer = payload.get("payer") or payload.get("from") or accepted.get("payer") amount_atoms = accepted.get("amount") or payload.get("amount") if not tx_hash: return {"verified": False, "reason": "No tx hash in payment payload"} # Prevent double-spend: atomic SET NX — no race condition r = get_redis() if r: spent_key = f"x402:spent_tx:{tx_hash}" spent = r.set(spent_key, "pending", ex=86400, nx=True) if not spent: return {"verified": False, "reason": f"Transaction {tx_hash[:16]}... already used"} # Resolve expected amount from tool pricing if not in payload tool_id = ( accepted.get("extra", {}).get("tool") or payload.get("tool") or accepted.get("resource", "").split("/")[-1] if accepted.get("resource") else None ) _ensure_tool_prices() if not amount_atoms and tool_id and tool_id in TOOL_PRICES: amount_atoms = TOOL_PRICES[tool_id].get("price_atoms") # ── Blockscout API v2 (free, no API key needed) ── # Maps chain_id → Blockscout base URL chain_id = chain_cfg.get("chain_id") blockscout_urls = { 1: "https://eth.blockscout.com", 42161: "https://arbitrum.blockscout.com", 10: "https://optimism.blockscout.com", 137: "https://polygon.blockscout.com", 8453: "https://base.blockscout.com", 100: "https://gnosis.blockscout.com", 43114: "https://avalanche.blockscout.com", 250: "https://fantom.blockscout.com", 56: "https://bsc.blockscout.com", } blockscout_base = blockscout_urls.get(chain_id) if not blockscout_base: return {"verified": False, "reason": f"No Blockscout URL for chain_id {chain_id}"} import httpx # Blockscout v2 API: /api/v2/transactions/{tx_hash} tx_url = f"{blockscout_base}/api/v2/transactions/{tx_hash}" async with httpx.AsyncClient(timeout=15) as client: resp = await client.get(tx_url) if resp.status_code != 200: return { "verified": False, "reason": f"TX {tx_hash[:16]}... not found on {chain_key} (HTTP {resp.status_code})", } tx_data = resp.json() # Check transaction status tx_status = tx_data.get("status") or tx_data.get("result") if tx_status != "ok" and tx_status != "1": return { "verified": False, "reason": f"TX {tx_hash[:16]}... failed on-chain (status={tx_status})", } # Decode ERC-20 Transfer events from receipt logs our_address = (EVM_PAY_TO or "").lower() usdc_address = chain_cfg["usdc"].lower() expected_amount = str(amount_atoms) if amount_atoms else None logs = tx_data.get("decoded_input", {}).get("logs") or tx_data.get("logs") or [] if not logs: # Try alternate: fetch receipt directly receipt_url = f"{blockscout_base}/api?module=transaction&action=gettxreceiptstatus&txhash={tx_hash}" async with httpx.AsyncClient(timeout=10) as client2: r2 = await client2.get(receipt_url) if r2.status_code == 200: receipt_data = r2.json() logs = receipt_data.get("result", {}).get("logs", []) or receipt_data.get( "logs", [] ) transfer_found = False amount_match = False actual_amount = None for log in logs: topics = log.get("topics", []) if len(topics) < 3: continue if topics[0].lower() != TRANSFER_EVENT_TOPIC0: continue if log.get("address", "").lower() != usdc_address: continue # Decode 'to' address from topic2 try: to_address = "0x" + topics[2][-40:].lower() except (IndexError, ValueError): continue if to_address != our_address: continue # Decode amount from data field transfer_found = True log_data = log.get("data", "0x") try: if log_data and log_data.startswith("0x") and len(log_data) >= 66: actual_amount = str(int(log_data[:66], 16)) elif log_data: actual_amount = str(int(log_data, 16)) except (ValueError, TypeError): logger.warning( f"Could not decode Transfer data for {tx_hash[:16]}...: {str(log_data)[:20]}" ) # Verify amount matches if expected_amount and actual_amount: if actual_amount == expected_amount: amount_match = True else: logger.warning( f"Amount mismatch for {tx_hash[:16]}...: " f"expected {expected_amount} atoms, got {actual_amount} atoms on {chain_key}" ) elif actual_amount: # Check if amount matches any tool price for _tid, pricing in TOOL_PRICES.items(): if str(pricing.get("price_atoms")) == actual_amount: amount_match = True break break # Found Transfer to our address if not transfer_found: return { "verified": False, "reason": f"No USDC Transfer to {our_address[:10]}... found in {tx_hash[:16]}...", } if not amount_match and expected_amount and actual_amount: return { "verified": False, "reason": f"Amount mismatch: expected {expected_amount} atoms, got {actual_amount} atoms", } # Mark as spent in Redis with 86400s (24h) TTL — update the existing NX lock tool_name = tool_id or "unknown" if r: spent_key = f"x402:spent_tx:{tx_hash}" payment_data = { "chain": chain_key, "payer": payer or "unknown", "amount": actual_amount or amount_atoms or "0", "tool": tool_name, "timestamp": time.time(), } r.setex(spent_key, 86400, json.dumps(payment_data)) # Persist to Supabase (non-blocking) try: import asyncio from app.routers.x402_dashboard import _persist_payment_to_supabase asyncio.create_task( _persist_payment_to_supabase( tool=tool_name, amount_atoms=str(actual_amount or amount_atoms or "0"), chain=chain_key, payer=payer or "unknown", tx_hash=tx_hash, status="fulfilled", ) ) except Exception: pass logger.info( f"Verified USDC payment: {tx_hash[:16]}... on {chain_key} " f"from {payer[:10] if payer else 'unknown'}... amount={actual_amount or 'unknown'} tool={tool_name}" ) return { "verified": True, "chain": chain_key, "tx_hash": tx_hash, "payer": payer, "amount": actual_amount or amount_atoms, "method": "blockscout-verify", } except Exception as e: logger.error(f"Self-verify error: {e}") return {"verified": False, "reason": f"Verification error: {str(e)[:100]}"} # ── Refund policy helpers ── def _record_refundable_payment( tx_hash: str, chain: str, payer: str, amount: str, tool: str, reason: str ): """Record a refundable payment in Redis when a tool returns empty data. Stores in Redis key 'x402:refund:{tx_hash}' with 7-day TTL. Actual USDC refund is a manual process — we send USDC back from whichever chain has funds. """ r = get_redis() if not r: logger.warning(f"Cannot record refund for {tx_hash}: Redis unavailable") return refund_key = f"x402:refund:{tx_hash}" refund_data = { "tx_hash": tx_hash, "chain": chain or "unknown", "payer": payer or "unknown", "amount_atoms": str(amount) if amount else "0", "tool": tool or "unknown", "reason": reason, "status": "refundable", # refundable -> requested -> processing -> completed "flagged_at": time.time(), "requested_at": None, "processed_at": None, } try: r.setex(refund_key, 7 * 86400, json.dumps(refund_data)) # 7-day TTL logger.info(f"Recorded refundable payment: {tx_hash[:16]}... tool={tool} reason={reason}") except Exception as e: logger.error(f"Failed to record refund for {tx_hash}: {e}") # ── Trial tracking (Redis-based) ── _redis_client: _redis_types.Redis | None | bool = None # bool = unavailable sentinel async def x402_enforcement_middleware(request: Request, call_next) -> Response: """ [DEPRECATED] Use domain/x402/middleware.py instead. x402 payment enforcement with free trial support. Intercepts /api/v1/x402-tools/* and returns 402 if no valid payment and no trials. This middleware is kept for backward compatibility. New routes should use app.domain.x402.middleware.require_payment() decorator instead. """ path = request.url.path # Only intercept x402-tools (DataBus handles its own payment via verify_x402_payment) if not path.startswith("/api/v1/x402-tools/"): return await call_next(request) # Add deprecation header to response response = await call_next(request) response.headers["X-Deprecated"] = "true" response.headers["X-Deprecated-Reason"] = "Use domain/x402/middleware.py instead" return response # Allow preflight if request.method == "OPTIONS": return await call_next(request) # ── Internal bypass: trial GET-to-POST re-dispatch ────────────── # When the middleware converts a trial GET to POST internally, # it sets X-RMI-Internal header. Skip enforcement on these. if request.headers.get("X-RMI-Internal") == "trial-granted": response = await call_next(request) response.headers["X-RMI-Trial"] = "true" remaining_hdr = request.headers.get("X-RMI-Trial-Remaining", "0") response.headers["X-RMI-Trial-Remaining"] = remaining_hdr for k, v in SECURITY_HEADERS.items(): if k not in response.headers: response.headers[k] = v return response # ── Free endpoints — always accessible, no payment required ────────── # These MUST be checked before bot detection so AI agents can discover us. # Discovery/catalog/framework endpoints are useless if bots can't reach them. FREE_GET_PATHS = { "/api/v1/x402-tools/discovery", "/api/v1/x402-tools/frameworks", "/api/v1/x402-tools/openai-tools", "/api/v1/x402-tools/anthropic-tools", "/api/v1/x402-tools/gemini-tools", "/api/v1/x402-tools/langchain-tools", "/api/v1/x402-tools/catalog", "/api/v1/x402-tools/bundles", "/api/v1/x402-tools/payment-methods", "/api/v1/bulletin-board/x402/info", } FREE_PATHS = { "/api/v1/x402-tools/discovery", "/api/v1/x402-tools/frameworks", "/api/v1/x402-tools/openai-tools", "/api/v1/x402-tools/anthropic-tools", "/api/v1/x402-tools/gemini-tools", "/api/v1/x402-tools/langchain-tools", "/api/v1/x402-tools/catalog", "/api/v1/x402-tools/bundles", "/api/v1/x402-tools/payment-methods", "/api/v1/bulletin-board/x402/info", "/api/v1/x402-tools/human-execute", "/api/v1/x402-tools/cache/stats", "/api/v1/x402-tools/cache/clear", "/api/v1/x402-tools/stream/alerts", "/api/v1/x402-tools/webhook_list", } # Also exempt all /api/v1/x402/ admin endpoints, /api/v1/developer/, /api/v1/analytics/, /api/v1/state/, /api/v1/status, /api/v1/webhooks/, and /api/v1/tools/ (changelog) endpoints # Also exempt /api/v1/x402/facilitator-health/ endpoints if ( path.rstrip("/") in FREE_PATHS or path.startswith("/api/v1/x402/") or path.startswith("/api/v1/developer/") or path.startswith("/api/v1/analytics/") or path.startswith("/api/v1/state/") or path.startswith("/api/v1/status") or path.startswith("/api/v1/webhooks/") or path.startswith("/api/v1/tools/changelog") or path.startswith("/api/v1/tools/version") or path.startswith("/api/v1/tools/deprecated") ): return await call_next(request) # GET requests to free paths are always allowed (discovery for agents) if request.method == "GET" and path.rstrip("/") in FREE_GET_PATHS: return await call_next(request) # ── Developer API Key Check (FREE tier) ───────────────────── # If request has a valid developer API key, bypass payment and use free tier limits. # This must be checked BEFORE payment enforcement and bot detection. try: from app.routers.developer_tier import check_developer_key dev_result = await check_developer_key(request) if dev_result is not None: # This IS a developer key request if dev_result.get("deny"): status_code = dev_result.get("status_code", 429) return JSONResponse( status_code=status_code, content={ "error": dev_result.get("error", "rate_limit_exceeded"), "tier": dev_result.get("tier", "free"), "message": dev_result.get("error", "Rate limit exceeded"), "daily_limit": dev_result.get("daily_limit"), "calls_today": dev_result.get("calls_today"), "upgrade_url": "https://rugmunch.io/mcp-docs#pricing", }, headers={ **SECURITY_HEADERS, "X-RMI-Tier": dev_result.get("tier", "free"), "X-RMI-Remaining": str(dev_result.get("remaining_today", 0)), "Retry-After": str(dev_result.get("retry_after", 60)), }, ) # Valid dev key — attach info to request state and bypass payment request.state.developer_key = True request.state.developer_tier = dev_result.get("tier", "free") request.state.developer_email = dev_result.get("email", "") # Continue to tool execution without payment check response = await call_next(request) response.headers["X-RMI-Tier"] = dev_result.get("tier", "free") response.headers["X-RMI-Remaining"] = str(dev_result.get("remaining_today", 0)) return response except ImportError: pass # developer_tier module not available — fall through to payment except Exception as e: logger.warning(f"Developer key check failed: {e}") # Fail open — don't block paying customers on dev tier errors pass # ── Bot / abuse detection ───────────────────────────────────── # Only block bots on PAID tool endpoints, not discovery user_agent = (request.headers.get("User-Agent", "") or "").lower() # Block known bot/scanner user agents ONLY on paid endpoints # Legitimate API clients (curl, python, etc.) are welcome — just need payment SCANNER_AGENTS = [ "masscan", "nmap", "nikto", "sqlmap", "dirbuster", "gobuster", "zgrab", "censysinspect", ] if any(bot in user_agent for bot in SCANNER_AGENTS): return JSONResponse( status_code=403, content={ "error": "Automated access requires x402 payment. Use x-pay header or a proper API client.", "docs": "https://rugmunch.io/mcp-docs", }, headers=SECURITY_HEADERS, ) # Rate limit: max 5 rapid same-tool requests per fingerprint per 60s (burst protection) client_id = get_client_id(request) # Extract tool name early for burst tracking path_tool_id = path.rstrip("/").split("/")[-1] if path.count("/") >= 4 else "unknown" r = get_redis() if r and re.match(r"^[a-zA-Z0-9_]+$", path_tool_id): burst_key = f"x402:burst:{client_id}:{path_tool_id}" try: BURST_LIMIT = int(os.getenv("X402_BURST_LIMIT", "5")) BURST_WINDOW = int(os.getenv("X402_BURST_WINDOW", "60")) pipe = r.pipeline() pipe.incr(burst_key) if not r.exists(burst_key): pipe.expire(burst_key, BURST_WINDOW) results = pipe.execute() burst_count = results[0] if results else 0 if burst_count > BURST_LIMIT: logger.warning( f"Burst limit: {client_id} hit {burst_count} requests in {BURST_WINDOW}s" ) return JSONResponse( status_code=429, content={ "error": f"Rate limited. Max {BURST_LIMIT} requests per {BURST_WINDOW}s per tool.", "retry_after": BURST_WINDOW, }, headers={**SECURITY_HEADERS, "Retry-After": str(BURST_WINDOW)}, ) except Exception: pass # Don't block on burst tracking errors # Free paths already checked above — flow continues to paid tool enforcement # Extract tool name tool_id = path.rstrip("/").split("/")[-1] # Input validation: reject tool IDs with suspicious characters if not re.match(r"^[a-zA-Z0-9_]+$", tool_id): return JSONResponse( status_code=400, content={"error": "Invalid tool identifier"}, headers=SECURITY_HEADERS, ) client_id = get_client_id(request) # ── Payment header parsing: support both v1 (x-pay) and v2 (PAYMENT-SIGNATURE) ── # v2 specifies PAYMENT-SIGNATURE header (base64 JSON), v1 uses X-Pay or x-pay pay_sig = request.headers.get("PAYMENT-SIGNATURE", "") or request.headers.get( "Payment-Signature", "" ) x_pay = request.headers.get("x-pay", "") or request.headers.get("X-Pay", "") # Reject oversized payment headers (DoS protection) if pay_sig and len(pay_sig) > 16384: return JSONResponse( status_code=400, content={"error": "Payment header too large"}, headers=SECURITY_HEADERS, ) # Size check BEFORE parsing — prevent DoS on oversized headers max_header_len = 8192 if (x_pay and len(x_pay) > max_header_len) or (pay_sig and len(pay_sig) > max_header_len): return JSONResponse( status_code=400, content={"error": "Payment header too large"}, headers=SECURITY_HEADERS, ) # Parse payment payload from whichever header is present # v2 PAYMENT-SIGNATURE takes priority over v1 x-pay payload = None if pay_sig: payload = parse_x_pay_header(pay_sig) # parse_x_pay handles base64 too if payload: # v2 spec: payload should have top-level x402Version, accepted, payload, resource # Normalize: make sure "accepted" is available at top level for our verifier if "accepted" not in payload and "payload" in payload: # This is a v2 PaymentPayload format — extract payment info pass # The verify_payment_via_router already handles this format elif x_pay: payload = parse_x_pay_header(x_pay) if payload: # Validate payment payload structure if not isinstance(payload, dict): return JSONResponse( status_code=400, content={"error": "Invalid payment payload structure"}, headers=SECURITY_HEADERS, ) accepted = payload.get("accepted", {}) if not isinstance(accepted, dict): return JSONResponse( status_code=400, content={"error": "Invalid accepted payment structure"}, headers=SECURITY_HEADERS, ) result = await verify_payment_via_router(payload) if result.get("verified"): # Payment OK — attach info and proceed request.state.x402_verified = True request.state.x402_payer = result.get("payer", "") request.state.x402_chain = result.get("chain", "") request.state.x402_tx_hash = result.get("tx_hash", "") request.state.x402_amount = result.get("amount", "") request.state.x402_method = result.get("method", "") # Track facilitator payment success for health monitoring facilitator = result.get("method", result.get("facilitator", "")) if facilitator: try: from app.routers.facilitator_health import record_payment_success record_payment_success(facilitator, True) except Exception: logger.debug(f"Could not record facilitator success for {facilitator}") response = await call_next(request) response.headers["X-RMI-Payment"] = "verified" # ── x402 v2: PAYMENT-RESPONSE header (base64 SettlementResponse) ── # The v2 spec requires a PAYMENT-RESPONSE header on 200 responses # after successful settlement, containing base64 JSON: # {success: true, transaction: "0x...", network: "eip155:8453", payer: "0x..."} settlement_response = { "success": True, "transaction": result.get("tx_hash", ""), "network": CHAIN_USDC.get(result.get("chain", ""), {}).get( "network", result.get("chain", "") ), "payer": result.get("payer", ""), } if result.get("amount"): settlement_response["amount"] = result.get("amount") try: payment_response_b64 = _base64.b64encode( json.dumps(settlement_response, separators=(",", ":")).encode() ).decode("ascii") response.headers["PAYMENT-RESPONSE"] = payment_response_b64 except Exception as e: logger.warning(f"Failed to set PAYMENT-RESPONSE header: {e}") # Add security headers to successful responses too for k, v in SECURITY_HEADERS.items(): if k not in response.headers: response.headers[k] = v # ── Refund policy: detect empty/no-data responses and auto-flag for refund ── # If a paid tool returns no real data, the payment should be refundable. # We record this in Redis; actual USDC refund is a manual process from our wallet. _should_flag_refund = False _refund_reason = "" try: # Read the response body to check for empty data resp_body = b"" async for chunk in response.body_iterator: resp_body += chunk # Reconstruct response with the read body response = Response( content=resp_body, status_code=response.status_code, media_type=response.media_type, headers={ k: v for k, v in response.headers.items() if k.lower() not in ("content-length", "transfer-encoding") }, ) if response.status_code >= 400: _should_flag_refund = True _refund_reason = f"HTTP {response.status_code} error response" elif resp_body: try: body_json = json.loads(resp_body) # Heuristic: empty data detection # No sources, no findings, empty result, or explicit error _has_sources = bool( body_json.get("sources_used") or body_json.get("sources") ) _has_findings = bool( body_json.get("findings") or body_json.get("results") ) _has_data = bool( body_json.get("data") or body_json.get("result") or body_json.get("report") ) _has_error = bool(body_json.get("error")) _is_empty = not (_has_sources or _has_findings or _has_data) if _is_empty and not _has_error: _should_flag_refund = True _refund_reason = "Tool returned no data (empty response)" elif _has_error and not _has_data: _should_flag_refund = True _refund_reason = ( f"Tool error: {str(body_json.get('error', ''))[:100]}" ) except (json.JSONDecodeError, ValueError): pass # Non-JSON body, treat as having data except Exception as e: logger.debug(f"Refund detection body read error: {e}") if _should_flag_refund: _record_refundable_payment( tx_hash=request.state.x402_tx_hash or "unknown", chain=request.state.x402_chain, payer=request.state.x402_payer, amount=request.state.x402_amount, tool=tool_id, reason=_refund_reason, ) response.headers["X-RMI-Refund-Flagged"] = "true" logger.info( f"Flagged payment for refund: tx={request.state.x402_tx_hash} " f"tool={tool_id} reason={_refund_reason}" ) return response # Payment verification failed — track failure for health monitoring facilitator = result.get("method", result.get("facilitator", "")) if facilitator: try: from app.routers.facilitator_health import record_payment_success record_payment_success(facilitator, False) except Exception: pass # No valid payment — check free trials try: can_trial, remaining = check_trial(tool_id, client_id, consume=False) except Exception as e: logger.error(f"Trial check failed for {tool_id}: {e}") can_trial, remaining = False, 0 if can_trial: # ── Consume the trial now (atomic INCR in Redis) ── # The pre-check above used consume=False; now we actually consume it try: _, remaining_after = check_trial(tool_id, client_id, consume=True) remaining = remaining_after except Exception as e: logger.error(f"Trial consumption failed for {tool_id}/{client_id}: {e}") # Allow free trial execution # ── Execute the tool directly via DataBus caching shield ── # This eliminates the fragile httpx GET-to-POST roundtrip. # All 127 tools are available through the DataBus — no need for # internal HTTP bouncing through POST-only route handlers. try: from app.caching_shield.tool_data import td # Build params from request (query params for GET, JSON body for POST) params = {} if request.method == "GET" and request.query_params: params = dict(request.query_params) elif request.method == "POST": try: params = await request.json() except Exception: params = {} result = await td.call_tool(tool_id, params) if result is not None: # Trial execution succeeded — return the result # Use raw Response (not JSONResponse) to avoid BaseHTTPMiddleware # body_iterator consumption bug when returning from middleware directly. response_body = json.dumps(result, default=str) response = Response( content=response_body, status_code=200, media_type="application/json", headers={ "X-RMI-Trial": "true", "X-RMI-Trial-Remaining": str(remaining), **SECURITY_HEADERS, }, ) return response else: # DataBus returned None — tool not available via DataBus. # For GET requests: try httpx GET-to-POST conversion. # For POST requests: fall through to original route handler. if request.method == "GET" and request.query_params: import httpx query_params = dict(request.query_params) base_url = str(request.url).split("?")[0] try: async with httpx.AsyncClient(timeout=30) as client: resp = await client.post( base_url, json=query_params, headers={ "Content-Type": "application/json", "X-RMI-Internal": "trial-granted", "X-RMI-Trial": "true", "X-RMI-Trial-Remaining": str(remaining), "X-Device-Id": request.headers.get("x-device-id", ""), }, ) if resp.status_code == 404: # Tool has no POST route handler (DataBus-only tool that failed). # Trial was consumed — return 200 with "no data" result. # The client got their free call, data just wasn't available. return Response( content=json.dumps( { "tool": tool_id, "result": "no_data_available", "trial": True, "trial_remaining": remaining, "message": "Free trial used. Data not available for this address — try a real token address.", } ), status_code=200, media_type="application/json", headers={ "X-RMI-Trial": "true", "X-RMI-Trial-Remaining": str(remaining), **SECURITY_HEADERS, }, ) response = Response( content=resp.content, status_code=resp.status_code, media_type=resp.headers.get("content-type", "application/json"), headers={ k: v for k, v in resp.headers.items() if k.lower() not in ("content-length", "transfer-encoding") }, ) except Exception: # httpx failed — return "no data" response (trial was consumed) return Response( content=json.dumps( { "tool": tool_id, "result": "no_data_available", "trial": True, "trial_remaining": remaining, } ), status_code=200, media_type="application/json", headers={ "X-RMI-Trial": "true", "X-RMI-Trial-Remaining": str(remaining), **SECURITY_HEADERS, }, ) else: # POST request or GET without params — try call_next response = await call_next(request) # If call_next returns 404, the tool has no POST route handler. # Trial was consumed — return "no data" response instead of 404. if response.status_code == 404: return Response( content=json.dumps( { "tool": tool_id, "result": "no_data_available", "trial": True, "trial_remaining": remaining, "message": "Free trial used. Data not available for this address — try a real token address.", } ), status_code=200, media_type="application/json", headers={ "X-RMI-Trial": "true", "X-RMI-Trial-Remaining": str(remaining), **SECURITY_HEADERS, }, ) response.headers["X-RMI-Trial"] = "true" response.headers["X-RMI-Trial-Remaining"] = str(remaining) for k, v in SECURITY_HEADERS.items(): if k not in response.headers: response.headers[k] = v return response except ImportError: logger.warning("DataBus caching shield not available, falling back to call_next") response = await call_next(request) response.headers["X-RMI-Trial"] = "true" response.headers["X-RMI-Trial-Remaining"] = str(remaining) for k, v in SECURITY_HEADERS.items(): if k not in response.headers: response.headers[k] = v return response except Exception as e: logger.error(f"Trial execution via DataBus failed for {tool_id}: {e}") # Trial consumed but execution failed — refund the trial try: r = get_redis() if r: key = f"x402:trial:{client_id}:{tool_id}" if r.exists(key): r.decr(key) # Roll back the consumption only if key exists except Exception: pass # Return 402 — trial should be refunded since we couldn't execute return build_402_response(tool_id, client_id=client_id) # No trials left — return 402 return build_402_response(tool_id, client_id=client_id) # ── Discovery Endpoint ── from fastapi import APIRouter discovery_router = APIRouter() # ── Main enforcement router (wrapper) ── router = APIRouter(prefix="/api/v1/x402", tags=["x402 Enforcement"]) # Note: discovery_router is included on the app directly in main.py at root path # so .well-known/x402 resolves at /.well-known/x402 (x402 spec requirement) def _build_discovery_response(): """Build the full discovery response with all 13 chains and active facilitators.""" tools = {} for tool_id, pricing in TOOL_PRICES.items(): try: price_atoms = str(pricing.get("price_atoms", "10000")) price_usd = float(pricing.get("price_usd", 0.01)) trial_free = int(pricing.get("trial_free", 1)) category = pricing.get("category", "analysis") except (ValueError, TypeError): continue requirements = [] for chain_key, cfg in CHAIN_USDC.items(): method = cfg["method"] pay_to = _resolve_pay_to(method) asset = _resolve_asset(cfg) extra = _build_extra(cfg, tool_id, chain_key, pay_to, method) req = { "scheme": "exact", "network": cfg["network"], "asset": asset, "amount": price_atoms, "payTo": pay_to, "maxTimeoutSeconds": 180, "extra": extra, "facilitators": cfg.get("facilitators", []), } if "tokens" in cfg: req["supportedTokens"] = list(cfg["tokens"].keys()) requirements.append(req) desc = pricing.get("description", "") if not desc: _TOOL_DESCRIPTIONS = { "forensic_valuation": "Institutional-grade token valuation — DCF intrinsic value, comparable analysis with outlier detection, scam probability scoring", "osint_identity_hunt": "Cross-platform OSINT — hunt usernames across 400+ networks, domain intelligence, stealth page capture", "investigation_report": "Full investigation report — on-chain forensics, financial valuation, OSINT, scam scoring in one deliverable", "forensic_pack": "Forensic Investigation Pack — valuation + OSINT + report at 33% discount", } desc = _TOOL_DESCRIPTIONS.get(tool_id, f"{tool_id} — crypto intelligence tool") tools[tool_id] = { "description": desc, "price_usd": price_usd, "category": category, "trial_free": trial_free, "trial_description": f"{trial_free} free calls before payment required", "requirements": requirements, } # Build spec-compliant supportedNetworks array supported_networks = [] for ck, cv in CHAIN_USDC.items(): net = { "network": cv["network"], "name": CHAIN_NAMES.get(ck, ck), "chainId": cv.get("chain_id"), "currency": cv.get("name", "USD Coin"), "facilitators": cv.get("facilitators", []), } if "tokens" in cv: net["tokens"] = dict(cv["tokens"].items()) supported_networks.append(net) # Build spec-compliant paymentFacilitators array facilitator_map = { "coinbase_cdp": { "name": "Coinbase CDP", "description": "Fee-free Base + Solana USDC via Coinbase Developer Platform", "url": "https://cdp.coinbase.com", }, "payai": { "name": "PayAI", "description": "Base + Solana USDC, deferred settlement", "url": "https://payai.network", }, "primev": { "name": "Primev", "description": "Fee-free Ethereum via mev-commit preconfirmations", "url": "https://primev.xyz", }, "cloudflare_x402": { "name": "Cloudflare x402", "description": "Base Sepolia + Ethereum fallback", "url": "https://x402.org", }, "eip7702": { "name": "EIP-7702 Universal", "description": "Universal EVM — BSC, Polygon, Avalanche, Fantom, Gnosis, Arbitrum, Optimism, Base", "url": "https://eips.ethereum.org/EIPS/eip-7702", }, "tron_selfverify": { "name": "TRON Self-Verify", "description": "TRON USDT/USDC/USDD — self-verified via TronGrid (fee-free)", "url": "https://trongrid.io", }, "bitcoin_selfverify": { "name": "Bitcoin Self-Verify", "description": "Bitcoin BTC — self-verified via Mempool.space (fee-free, 1-conf)", "url": "https://mempool.space", }, "asterpay": { "name": "AsterPay", "description": "EUR/SEPA European off-ramp", "url": "https://asterpay.io", }, } payment_facilitators = [] for fk, fv in facilitator_map.items(): fac_networks = [n["network"] for n in supported_networks if fk in n.get("facilitators", [])] payment_facilitators.append( { "name": fv["name"], "description": fv["description"], "url": fv["url"], "supportedNetworks": fac_networks, } ) return { "x402": { "version": "2", "protocol": "x402", "description": "Rug Munch Intelligence (RMI) — Multi-chain x402 payment system. Crypto scam detection, market analysis, and security intelligence via micropayments.", "trial_policy": "1 free trial per tool without wallet. Connect wallet for 3 free calls per standard tool, 1 per premium tool.", "refund_policy": "Full refund if tool returns no data. Request within 48h via POST /api/v1/x402/refund with tx hash.", "facilitator_summary": { "coinbase_cdp": "Fee-free Base + Solana USDC via Coinbase Developer Platform", "payai": "Base + Solana USDC, deferred settlement", "primev": "Fee-free Ethereum via mev-commit preconfirmations", "cloudflare_x402": "Base Sepolia + Ethereum fallback", "eip7702": "Universal EVM — BSC, Polygon, Avalanche, Fantom, Gnosis, Arbitrum, Optimism, Base", "tron_selfverify": "TRON USDT/USDC/USDD — self-verified via TronGrid (fee-free)", "bitcoin_selfverify": "Bitcoin BTC — self-verified via Mempool.space (fee-free, 1-conf)", "asterpay": "EUR/SEPA European off-ramp", "x402_rs": "Self-hosted x402-rs — multi-chain (requires Docker)", }, }, "gateway_url": "https://mcp.rugmunch.io", "payment_endpoint": "https://mcp.rugmunch.io/api/v1/x402-tools", "supported_chains": list(CHAIN_USDC.keys()), "supportedNetworks": supported_networks, "paymentFacilitators": payment_facilitators, "chain_count": len(CHAIN_USDC), "facilitator_count": len(payment_facilitators), "total_tools": len(tools), "tools": tools, } def _resolve_pay_to(method: str) -> str: _ensure_pay_addresses() if method == "payai": return SOL_PAY_TO or "" # type: ignore[return-value] elif method == "bitcoin_selfverify": return os.getenv("X402_BTC_PAY_TO", "") elif method == "tron_selfverify": return os.getenv("X402_TRON_PAY_TO", "") elif method == "asterpay": return os.getenv("ASTERPAY_SEPA_IBAN", "") return EVM_PAY_TO or "" # type: ignore[return-value] def _resolve_asset(cfg: dict) -> str: asset = cfg.get("usdc", "") if not asset and "tokens" in cfg: first = next(iter(cfg["tokens"].values()), "") return first if first != "native" else "BTC" return asset def _build_extra(cfg: dict, tool_id: str, chain_key: str, pay_to: str, method: str) -> dict: extra = { "name": cfg["name"], "version": cfg["version"], "tool": tool_id, "chain": chain_key, } if method == "local_eip712" and cfg.get("chain_id"): extra["domain"] = { "name": cfg["name"], "version": cfg["version"], "chainId": cfg["chain_id"], "verifyingContract": pay_to, } elif method == "payai": extra["feePayer"] = "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4" elif method == "tron_selfverify": extra["tronNetwork"] = "mainnet" extra["trc20Tokens"] = cfg.get("tokens", {}) elif method == "bitcoin_selfverify": extra["paymentNetwork"] = "bitcoin" extra["settlementChains"] = ["base", "solana"] elif method == "asterpay": extra["currency"] = "EUR" extra["sepa"] = True return extra @discovery_router.get("/.well-known/x402") async def x402_discovery(): """x402 discovery endpoint — lists all tools, chains, payment requirements, and trial info. Response is cached for DISCOVERY_CACHE_TTL seconds to avoid rebuilding 7-chain requirements per request. """ global _discovery_cache, _discovery_cache_time now = time.time() if _discovery_cache is not None and (now - _discovery_cache_time) < DISCOVERY_CACHE_TTL: return _discovery_cache _discovery_cache = _build_discovery_response() _discovery_cache_time = now return _discovery_cache # ── Trial Status Endpoint ── @discovery_router.get("/api/v1/x402/trial-status") async def get_trial_status(request: Request): """Check remaining free trials for the current client""" client_id = get_client_id(request) trials = {} for tool_id, pricing in TOOL_PRICES.items(): max_trials = pricing.get("trial_free", 3) if max_trials > 0: r = get_redis() if r: try: used = int(r.get(f"x402:trial:{client_id}:{tool_id}") or 0) except Exception: used = 0 else: used = 0 trials[tool_id] = { "max": max_trials, "used": used, "remaining": max(0, max_trials - used), } return { "client_id": client_id[:20] + "..." if len(client_id) > 20 else client_id, "trials": trials, "tools_with_trials": len(trials), } # ── Revenue Read Endpoint ── @discovery_router.get("/api/v1/x402/revenue") async def get_revenue(request: Request = None): """Read cumulative x402 revenue stats from Redis counters. AUTH: Requires X-API-Key matching ADMIN_API_KEY. Returns total revenue, daily breakdown, tool-level stats, and payment counts. Revenue counters are incremented by POST /api/v1/x402/receipt on successful payments. """ _ensure_pay_addresses() if request: admin_key = os.getenv("ADMIN_API_KEY", "") if admin_key: auth = request.headers.get("X-API-Key", "") or request.headers.get("Authorization", "").replace("Bearer ", "") if auth != admin_key: return JSONResponse(status_code=403, content={"error": "Admin API key required"}) r = get_redis() if not r: return JSONResponse( status_code=503, content={"error": "Redis unavailable, cannot read revenue"}, headers=SECURITY_HEADERS, ) try: total_revenue = float(r.get("x402:revenue:total") or 0) total_calls = 0 tool_revenue = {} tool_calls = {} # Scan tool-level keys for key in r.scan_iter("x402:tool_revenue:*"): tool_id = key.decode().replace("x402:tool_revenue:", "") tool_revenue[tool_id] = float(r.get(key) or 0) for key in r.scan_iter("x402:tool_calls:*"): tool_id = key.decode().replace("x402:tool_calls:", "") count = int(r.get(key) or 0) tool_calls[tool_id] = count total_calls += count # Daily breakdown (last 30 days) daily = {} from datetime import datetime, timedelta for i in range(30): day = (datetime.utcnow() - timedelta(days=i)).strftime("%Y-%m-%d") val = float(r.get(f"x402:revenue:daily:{day}") or 0) if val > 0: daily[day] = val return { "total_revenue_usd": round(total_revenue, 2), "total_tool_calls": total_calls, "by_tool": { tid: { "revenue_usd": round(tool_revenue.get(tid, 0), 2), "calls": tool_calls.get(tid, 0), } for tid in set(list(tool_revenue.keys()) + list(tool_calls.keys())) }, "daily": daily, "payment_wallets": { "evm": EVM_PAY_TO, "solana": SOL_PAY_TO, "tron": os.getenv("X402_TRON_PAY_TO", ""), "bitcoin": os.getenv("X402_BTC_PAY_TO", ""), }, } except Exception as e: logger.error(f"Revenue read error: {e}") return JSONResponse( status_code=500, content={"error": f"Failed to read revenue: {str(e)[:100]}"}, headers=SECURITY_HEADERS, ) # ── #8 Auto-Pricing Suggestions ────────────────────────────────────── @discovery_router.get("/api/v1/x402/analytics/pricing-suggestions") async def get_pricing_suggestions(): """Auto-pricing suggestions based on conversion rates and usage data. Algorithm: - Pull call counts + revenue + trial counts per tool from Redis - Compute conversion rate = paid_calls / (paid_calls + trial_calls) - Tools with <5% conversion after 100+ trials → PRICE TOO HIGH → suggest decrease - Tools with >40% conversion → PRICE TOO LOW → suggest increase - Tools with 0 calls in 7 days → DEAD → suggest archive """ r = get_redis() if not r: return JSONResponse( status_code=503, content={"error": "Redis unavailable"}, headers=SECURITY_HEADERS, ) try: suggestions = [] for tool_id, pricing in TOOL_PRICES.items(): current_price = pricing.get("price_usd", 0) trial_free = pricing.get("trial_free", 3) # Get usage stats from Redis paid_calls = int(r.get(f"x402:tool_calls:{tool_id}") or 0) trial_calls = int(r.get(f"x402:trial_calls:{tool_id}") or 0) revenue = float(r.get(f"x402:tool_revenue:{tool_id}") or 0) total_calls = paid_calls + trial_calls conversion_rate = paid_calls / total_calls if total_calls > 0 else 0 avg_revenue_per_call = revenue / paid_calls if paid_calls > 0 else 0 suggestion = None suggested_price = current_price if total_calls < 10: suggestion = "insufficient_data" elif total_calls > 100 and conversion_rate < 0.05: suggestion = "decrease_price" suggested_price = round(current_price * 0.6, 3) elif conversion_rate > 0.40: suggestion = "increase_price" suggested_price = round(current_price * 1.3, 3) elif total_calls > 50 and conversion_rate < 0.15: suggestion = "consider_decrease" suggested_price = round(current_price * 0.8, 3) elif paid_calls == 0 and trial_calls > trial_free * 10: suggestion = "price_barrier" suggested_price = round(current_price * 0.5, 3) elif revenue > 0 and avg_revenue_per_call < current_price * 0.5: suggestion = "check_pricing_consistency" suggestions.append( { "tool_id": tool_id, "current_price_usd": current_price, "suggested_price_usd": suggested_price, "suggestion": suggestion, "stats": { "paid_calls": paid_calls, "trial_calls": trial_calls, "total_calls": total_calls, "conversion_rate": round(conversion_rate * 100, 1), "revenue_usd": round(revenue, 4), "avg_revenue_per_call": round(avg_revenue_per_call, 4), }, } ) # Sort: actionable suggestions first suggestions.sort( key=lambda s: 0 if s["suggestion"] and s["suggestion"] != "insufficient_data" else 1 ) return { "suggestions": suggestions, "total_tools": len(suggestions), "actionable": sum( 1 for s in suggestions if s["suggestion"] and s["suggestion"] != "insufficient_data" ), } except Exception as e: logger.error(f"Pricing suggestions error: {e}") return JSONResponse( status_code=500, content={"error": str(e)[:100]}, headers=SECURITY_HEADERS, ) # ── Refund Request Endpoint ── def _verify_refund_ownership(refund_payer: str, tx_hash: str) -> bool: """Verify the requester controls the payer address by checking on-chain. Proof-of-ownership: The requester must sign a message with the payer wallet. We verify by checking that the payer address matches the one stored in our payment records for this transaction. Without this check, anyone who knows a tx_hash could claim a refund for someone else's payment. Production: require a signed message (EIP-191 / Solana) proving wallet ownership. For v1, we verify the payer matches our records. """ r = get_redis() if not r: return False spent_data = r.get(f"x402:spent_tx:{tx_hash}") if not spent_data: return False try: spent_info = json.loads(spent_data) recorded_payer = spent_info.get("payer", "").lower() return bool(refund_payer and recorded_payer and refund_payer.lower() == recorded_payer) except (json.JSONDecodeError, TypeError, AttributeError): return False @router.post("/refund") async def request_refund(request: Request): """Request a refund for a paid tool that returned no data. Validates: (a) tx exists in our payment records (x402:spent_tx or x402:refund) (b) Proof of ownership: requester controls the payer address (c) within 48h of original payment If valid, records the refund request. Actual USDC refund is manual. POST body: { "tx_hash": "0x...", "payer": "0x..." (REQUIRED — your wallet address that paid), "signature": "0x..." (optional — EIP-191 signed message for v2), "reason": "Tool returned empty data" (optional) } """ try: body = await request.json() tx_hash = body.get("tx_hash", "").strip() requester_payer = body.get("payer", "").strip() requester_reason = body.get("reason", "").strip() if not tx_hash: return JSONResponse( status_code=400, content={"error": "tx_hash is required"}, headers=SECURITY_HEADERS, ) if not requester_payer: return JSONResponse( status_code=400, content={"error": "payer address is required (proof of ownership)"}, headers=SECURITY_HEADERS, ) # Proof of ownership: verify requester controls the payer address if not _verify_refund_ownership(requester_payer, tx_hash): return JSONResponse( status_code=403, content={ "error": "Proof of ownership failed. The payer address does not match our records for this transaction.", "tx_hash": tx_hash[:16] + "...", "note": "Provide the wallet address that originally paid for this transaction.", }, headers=SECURITY_HEADERS, ) r = get_redis() if not r: return JSONResponse( status_code=503, content={"error": "Redis unavailable, cannot process refund request"}, headers=SECURITY_HEADERS, ) # (a) Check if tx exists in our payment records spent_data = r.get(f"x402:spent_tx:{tx_hash}") existing_refund = r.get(f"x402:refund:{tx_hash}") if not spent_data and not existing_refund: return JSONResponse( status_code=404, content={ "error": "Transaction not found in payment records", "tx_hash": tx_hash[:16] + "...", }, headers=SECURITY_HEADERS, ) # If there's already a refund record, check its status if existing_refund: try: refund_info = json.loads(existing_refund) status = refund_info.get("status", "unknown") if status in ("requested", "processing", "completed"): return { "status": "already_requested", "refund_status": status, "tx_hash": tx_hash[:16] + "...", "message": f"Refund already {status}", "amount_atoms": refund_info.get("amount_atoms"), "chain": refund_info.get("chain"), } except (json.JSONDecodeError, TypeError): pass # (b) Check if payment was flagged as refundable (tool returned no data/error) refund_reason = requester_reason or "User-requested refund" refund_chain = "unknown" refund_payer = requester_payer or "unknown" refund_amount = "0" payment_timestamp = None if existing_refund: try: refund_info = json.loads(existing_refund) if refund_info.get("status") == "refundable": refund_reason = refund_info.get("reason") or refund_reason refund_chain = refund_info.get("chain", "unknown") refund_payer = refund_info.get("payer", requester_payer) refund_amount = refund_info.get("amount_atoms", "0") payment_timestamp = refund_info.get("flagged_at") except (json.JSONDecodeError, TypeError): pass elif spent_data: # Payment exists but wasn't auto-flagged — user is requesting manually # Allow if within 48h window and user provides a reason try: spent_info = json.loads(spent_data) refund_chain = spent_info.get("chain", "unknown") refund_payer = spent_info.get("payer", requester_payer) refund_amount = spent_info.get("amount", "0") payment_timestamp = spent_info.get("timestamp") except (json.JSONDecodeError, TypeError): # Old format: spent_data was just the chain name string refund_chain = spent_data if isinstance(spent_data, str) else "unknown" # (c) Verify within 48h of original payment if payment_timestamp: hours_since_payment = (time.time() - float(payment_timestamp)) / 3600 if hours_since_payment > 48: return JSONResponse( status_code=400, content={ "error": "Refund window expired (48h)", "hours_since_payment": round(hours_since_payment, 1), "tx_hash": tx_hash[:16] + "...", }, headers=SECURITY_HEADERS, ) # Record the refund request refund_key = f"x402:refund:{tx_hash}" refund_data = { "tx_hash": tx_hash, "chain": refund_chain, "payer": refund_payer, "amount_atoms": str(refund_amount), "tool": "unknown", "reason": refund_reason, "status": "requested", "flagged_at": payment_timestamp, "requested_at": time.time(), "processed_at": None, } r.setex(refund_key, 7 * 86400, json.dumps(refund_data)) # 7-day TTL logger.info( f"Refund requested: tx={tx_hash[:16]}... chain={refund_chain} " f"amount={refund_amount} reason={refund_reason}" ) return { "status": "requested", "tx_hash": tx_hash[:16] + "...", "chain": refund_chain, "amount_atoms": str(refund_amount), "reason": refund_reason, "message": "Refund request recorded. Manual processing within 48h. You will receive USDC back on the original payment chain.", "estimated_processing": "24-48 hours", } except Exception as e: logger.error(f"Refund request error: {e}") return JSONResponse( status_code=500, content={"error": f"Refund request failed: {str(e)[:100]}"}, headers=SECURITY_HEADERS, )