""" RMI x402 Payment Middleware — Proper x402 Protocol Integration ================================================================ Uses the official x402 SDK for payment verification, receipt validation, and public transparency. Replaces the hacky direct facilitator POST. Components: 1. x402_middleware.py — FastAPI middleware for payment verification 2. /api/v1/x402/receipt/{id}/verify — Public receipt verification 3. /api/v1/x402/ledger — Public payment ledger (anonymized) 4. /api/v1/x402/transparency — System trust metrics Author: RMI Development Date: 2026-05-03 """ import contextlib import hashlib import json import logging import time from datetime import UTC, datetime from typing import Any from fastapi import APIRouter, HTTPException, Request logger = logging.getLogger("x402_middleware") router = APIRouter(prefix="/api/v1/x402", tags=["x402-payment-middleware"]) # ── x402 SDK Integration ────────────────────────────────────── # Import x402 SDK components try: from x402 import ( X402_VERSION, Network, PaymentPayload, PaymentRequirements, parse_payment_payload, parse_payment_required, x402Facilitator, ) X402_SDK_AVAILABLE = True logger.info(f"x402 SDK v{X402_VERSION} loaded") except ImportError as e: X402_SDK_AVAILABLE = False logger.warning(f"x402 SDK not available: {e}") # ── Payment Verification Engine ──────────────────────────────── # ERC-20 token metadata for EIP-712 domain verification # Maps (chain_id, token_address) -> (name, version) TOKEN_METADATA = { # USDC on Base mainnet (8453, "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"): ("USD Coin", "2"), # USDC on Base Sepolia (84532, "0x036cbd53842c5426634e7929541ec2318f3dcf7e"): ("USD Coin", "2"), } # Network configs with correct facilitator URLs FACILITATOR_CONFIGS = { "base": { "network": "eip155:8453", "chain_id": 8453, # x402.org doesn't support Base mainnet, use PayAI "url": "https://facilitator.payai.network", }, "base-sepolia": { "network": "eip155:84532", "chain_id": 84532, "url": "https://x402.org/facilitator", }, "solana": { "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "url": "https://facilitator.payai.network", }, "solana-devnet": { "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", "url": "https://facilitator.payai.network", }, } class PaymentVerifier: """ x402 payment verification with local EIP-712 verification for Base and facilitator fallback for other networks. Primary: Local EIP-712 signature verification (no external dependency) Fallback: PayAI facilitator via HTTP """ def __init__(self): self._facilitators: dict[str, Any] = {} def _verify_eip712_local(self, payload_data: dict) -> dict[str, Any]: """ Verify EIP-712 signed EIP-3009 authorization locally. No external facilitator needed — pure cryptographic verification. Uses the name/version from the payment's extra field (matches what SDK signed with). """ try: from eth_account import Account from eth_account.messages import encode_typed_data from x402.mechanisms.evm.eip712 import build_typed_data_for_signing from x402.mechanisms.evm.types import DOMAIN_TYPES, ExactEIP3009Authorization from x402.mechanisms.evm.utils import get_evm_chain_id accepted = payload_data.get("accepted", {}) inner = payload_data.get("payload", {}) auth = inner.get("authorization", {}) signature = inner.get("signature", "") if not all([auth, signature, accepted]): return {"verified": False, "reason": "Missing authorization or signature"} # Build typed data exactly as the SDK client did chain_id = get_evm_chain_id(accepted["network"]) authorization = ExactEIP3009Authorization( from_address=auth["from"], to=auth["to"], value=auth["value"], valid_after=auth["validAfter"], valid_before=auth["validBefore"], nonce=auth["nonce"], ) # Get name/version from extra (this is what the SDK used to sign) extra = accepted.get("extra", {}) token_name = extra.get("name", "USD Coin") token_version = extra.get("version", "2") domain, types, primary_type, message = build_typed_data_for_signing( authorization, chain_id, accepted["asset"], token_name, token_version ) # Verify time validity now = int(time.time()) valid_before = int(auth["validBefore"]) valid_after = int(auth["validAfter"]) if valid_before < now + 6: return {"verified": False, "reason": "Authorization expired (validBefore passed)"} if valid_after > now: return { "verified": False, "reason": "Authorization not yet valid (validAfter in future)", } # Build typed data for eth_account verification all_types = {**DOMAIN_TYPES, **types} typed_data = { "types": {k: [{"name": f["name"], "type": f["type"]} for f in v] for k, v in all_types.items()}, "primaryType": primary_type, "domain": { "name": domain.name, "version": domain.version, "chainId": domain.chain_id, "verifyingContract": domain.verifying_contract, }, "message": {k: ("0x" + v.hex() if isinstance(v, bytes) else str(v)) for k, v in message.items()}, } # Recover signer from signature signable = encode_typed_data(full_message=typed_data) recovered = Account.recover_message(signable, signature=signature) if recovered.lower() != auth["from"].lower(): return { "verified": False, "reason": f"Signature mismatch: recovered {recovered}, expected {auth['from']}", } # Verify recipient matches payTo if auth["to"].lower() != accepted.get("payTo", "").lower(): return {"verified": False, "reason": "Recipient address mismatch"} # Verify amount matches if str(auth["value"]) != str(accepted.get("amount", "")): return {"verified": False, "reason": "Amount mismatch"} return { "verified": True, "reason": "EIP-712 signature verified locally", "payer": auth["from"], "tx_hash": None, } except Exception as e: logger.error(f"Local EIP-712 verification error: {e}") return {"verified": False, "reason": f"Local verification error: {e!s}"} async def _verify_via_payai( self, payload_data: dict, requirements_data: dict | None, ) -> dict[str, Any]: """Fallback: verify via PayAI facilitator HTTP.""" import aiohttp # Build flattened requirements format PayAI expects accepted = payload_data.get("accepted", {}) flat_req = { "x402Version": 2, "scheme": accepted.get("scheme", "exact"), "network": accepted.get("network", "eip155:8453"), "asset": accepted.get("asset", ""), "amount": accepted.get("amount", ""), "payTo": accepted.get("payTo", ""), "maxTimeoutSeconds": accepted.get("maxTimeoutSeconds", 60), "extra": accepted.get("extra", {}), "resource": {}, } if requirements_data: flat_req["resource"] = requirements_data.get("resource", {}) body = { "x402Version": 2, "paymentPayload": payload_data, "paymentRequirements": flat_req, } try: async with aiohttp.ClientSession() as session, session.post( "https://facilitator.payai.network/verify", json=body, headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=15), ) as resp: data = await resp.json() if data.get("isValid"): return { "verified": True, "reason": "Verified via PayAI", "payer": data.get("payer"), "tx_hash": None, } return { "verified": False, "reason": f"PayAI: {data.get('invalidReason', 'unknown')} - {data.get('invalidMessage', '')}", "payer": data.get("payer"), } except Exception as e: logger.error(f"PayAI verification error: {e}") return {"verified": False, "reason": f"PayAI connection error: {e!s}"} async def verify_payment( self, x402_payload: str, network_key: str = "base", expected_amount: str | None = None, expected_tool: str | None = None, ) -> dict[str, Any]: """ Verify an x402 payment payload with layered verification. Priority: 1. Local EIP-712 verification (Base/EVM, instant, no external deps) 2. PayAI facilitator fallback """ # Step 1: Parse the payment payload try: payload_data = json.loads(x402_payload) except json.JSONDecodeError: return { "verified": False, "reason": "Invalid JSON in x402 payload", "tx_hash": None, "amount": None, "network": network_key, "settlement_id": None, } # Step 2: Local EIP-712 verification (primary for Base/EVM) if network_key.startswith("base"): local_result = self._verify_eip712_local(payload_data) if local_result["verified"]: return { "verified": True, "reason": local_result["reason"], "tx_hash": local_result.get("tx_hash"), "amount": expected_amount, "network": network_key, "settlement_id": None, "payer": local_result.get("payer"), } logger.warning(f"Local verification failed: {local_result['reason']}") # Fall through to facilitator # Step 3: Facilitator fallback config = FACILITATOR_CONFIGS.get(network_key, FACILITATOR_CONFIGS.get("base")) config["url"] # Build requirements from payload data for facilitator requirements_data = None try: accepted = payload_data.get("accepted", {}) resource = payload_data.get("resource", {}) requirements_data = { "x402Version": payload_data.get("x402Version", 2), "resource": resource, "accepts": [ { "scheme": accepted.get("scheme", "exact"), "network": accepted.get("network", config["network"]), "asset": accepted.get("asset", ""), "amount": accepted.get("amount", ""), "payTo": accepted.get("payTo", ""), "maxTimeoutSeconds": accepted.get("maxTimeoutSeconds", 60), "extra": accepted.get("extra", {}), } ], } except Exception: pass return await self._verify_via_payai(payload_data, requirements_data) # Global verifier instance _verifier = PaymentVerifier() # ── Public Receipt Verification ─────────────────────────────── @router.get("/receipt/{receipt_id}/verify") async def verify_receipt_public(receipt_id: str): """ Public endpoint to verify any x402 receipt. Anyone can check if a payment was fulfilled — no auth needed. Returns: receipt data + on-chain proof + verification status. """ try: from app.auth import get_redis r = await get_redis() receipt_data = await r.get(f"x402:receipt:{receipt_id}") if not receipt_data: raise HTTPException(status_code=404, detail="Receipt not found") receipt = json.loads(receipt_data) # Compute integrity hash for transparency integrity_data = f"{receipt_id}:{receipt.get('amount')}:{receipt.get('status')}:{receipt.get('timestamp')}" integrity_hash = hashlib.sha256(integrity_data.encode()).hexdigest()[:16] # Determine verification status verification_status = "unverified" if receipt.get("status") == "fulfilled": verification_status = "verified" if receipt.get("payment_tx"): verification_status = "on_chain_confirmed" elif receipt.get("status") == "refunded": verification_status = "refunded" return { "receipt_id": receipt_id, "status": receipt.get("status"), "verification": verification_status, "amount": receipt.get("amount"), "asset": receipt.get("asset"), "network": receipt.get("network"), "tool": receipt.get("tool"), "customer": receipt.get("customer_address", "")[:8] + "..." if receipt.get("customer_address") else None, "timestamp": receipt.get("timestamp"), "payment_tx": receipt.get("payment_tx"), "delivery_status": receipt.get("delivery_status"), "integrity_hash": integrity_hash, "chain_of_custody": { "stored": receipt.get("updated_at") is not None, "fulfilled": receipt.get("status") == "fulfilled", "on_chain": receipt.get("payment_tx") is not None and receipt.get("payment_tx") != "", }, } except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ── Public Payment Ledger ───────────────────────────────────── @router.get("/ledger") async def payment_ledger( limit: int = 50, offset: int = 0, tool: str | None = None, network: str | None = None, status: str | None = None, ): """ Public anonymized payment ledger for transparency. Shows: tool used, amount, network, timestamp, status. No wallet addresses, no PII. """ try: from app.auth import get_redis r = await get_redis() # Get recent receipt IDs all_ids = await r.zrevrange("x402:receipts_by_time", offset, offset + limit - 1) ledger = [] for rid in all_ids: rid_str = rid.decode() if isinstance(rid, bytes) else rid data = await r.get(f"x402:receipt:{rid_str}") if not data: continue receipt = json.loads(data) # Filter by tool/network/status if specified if tool and receipt.get("tool") != tool: continue if network and receipt.get("network") != network: continue if status and receipt.get("status") != status: continue # Anonymize customer customer = receipt.get("customer_address", "") anon_customer = customer[:6] + "..." + customer[-4:] if len(customer) > 10 else None ledger.append( { "receipt_id": rid_str, "tool": receipt.get("tool"), "amount": receipt.get("amount"), "asset": receipt.get("asset"), "network": receipt.get("network"), "status": receipt.get("status"), "delivery": receipt.get("delivery_status"), "timestamp": receipt.get("timestamp"), "customer": anon_customer, } ) # Get total count total = await r.zcard("x402:receipts_by_time") return { "ledger": ledger, "count": len(ledger), "total": total, "offset": offset, "limit": limit, } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ── Transparency Dashboard ──────────────────────────────────── @router.get("/transparency") async def transparency_dashboard(hours: int = 24): """ Public transparency dashboard showing system trust metrics. No sensitive data — all aggregated and anonymized. """ try: from app.auth import get_redis r = await get_redis() cutoff = time.time() - (hours * 3600) recent_ids = await r.zrangebyscore("x402:receipts_by_time", cutoff, "+inf") total = fulfilled = refunded = failed = 0 total_revenue = 0.0 tool_counts: dict[str, int] = {} network_counts: dict[str, int] = {} # hourly array intentionally skipped — data is fetched on-demand for rid in recent_ids: rid_str = rid.decode() if isinstance(rid, bytes) else rid data = await r.get(f"x402:receipt:{rid_str}") if not data: continue receipt = json.loads(data) total += 1 status = receipt.get("status", "unknown") if status == "fulfilled": fulfilled += 1 elif status == "refunded": refunded += 1 elif status in ("failed", "error"): failed += 1 # Revenue tracking (amounts stored in atomic units, convert to USDC) try: amount = float(receipt.get("amount", 0)) total_revenue += amount / 1e6 except (ValueError, TypeError): pass # Tool breakdown tool_name = receipt.get("tool", "unknown") tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1 # Network breakdown net = receipt.get("network", "unknown") network_counts[net] = network_counts.get(net, 0) + 1 # Get trial stats using SCAN (KEYS is disabled) total_trials_used = 0 cursor = 0 while True: cursor, keys = await r.scan(cursor, match="x402:trial:*", count=100) for k in keys: val = await r.get(k) if val: with contextlib.suppress(ValueError, TypeError): total_trials_used += int(val) if cursor == 0: break # Get ban stats ip_bans = wallet_bans = 0 async for key in r.scan_iter(match="x402:ban:ip:*"): k = key.decode() if isinstance(key, bytes) else key if ":reason:" not in k: ip_bans += 1 async for key in r.scan_iter(match="x402:ban:wallet:*"): wallet_bans += 1 fulfillment_rate = fulfilled / max(1, total) refund_rate = refunded / max(1, total) return { "period_hours": hours, "payments": { "total": total, "fulfilled": fulfilled, "refunded": refunded, "failed": failed, "fulfillment_rate": round(fulfillment_rate, 4), "refund_rate": round(refund_rate, 4), "total_revenue_usdc": round(total_revenue, 4), }, "tools": dict(sorted(tool_counts.items(), key=lambda x: -x[1])[:10]), "networks": network_counts, "trials": { "total_used": total_trials_used, }, "security": { "ip_bans_active": ip_bans, "wallet_bans_active": wallet_bans, "ban_duration_hours": 24, }, "trust_score": round( (fulfillment_rate * 0.6 + (1 - refund_rate) * 0.3 + (1 - failed / max(1, total)) * 0.1) * 100, 1, ), "timestamp": datetime.now(UTC).isoformat(), } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ── Payment Verification Endpoint (for gateway use) ──────────── @router.post("/verify") async def verify_payment_endpoint( request: Request, ): """ Verify an x402 payment payload. Used by the gateway worker to verify payments before executing tools. Exempt from rate limiting — gateway workers call this for every paid request. POST body: { "payload": "", "network": "base" | "solana", "tool": "audit" | "rugshield" | ..., "amount": "$0.02" | ..., "amount_atomic": "20000" | ... } Returns: { "verified": true/false, "reason": "...", "tx_hash": "...", "settlement_id": "...", } """ try: body = await request.json() x402_payload = body.get("payload", "") network = body.get("network", "base") tool = body.get("tool") amount = body.get("amount") if not x402_payload: raise HTTPException(status_code=400, detail="Missing x402 payload") result = await _verifier.verify_payment( x402_payload, network_key=network, expected_amount=amount, expected_tool=tool, ) # Store verification result for audit trail if result["verified"]: try: from app.auth import get_redis r = await get_redis() verify_key = f"x402:verify:{result.get('settlement_id') or str(time.time())}" await r.set( verify_key, json.dumps( { "verified_at": datetime.now(UTC).isoformat(), "network": network, "tool": tool, "amount": amount, "tx_hash": result.get("tx_hash"), "result": result["reason"], } ), ex=7 * 86400, ) # Keep for 7 days except Exception as e: logger.debug(f"Audit store failed: {e}") return result except HTTPException: raise except Exception as e: logger.error(f"Payment verification error: {e}") raise HTTPException(status_code=500, detail=str(e)) # ── Receipt Storage Endpoint (called by x402 Workers after payment) ──── @router.post("/receipt") async def store_receipt(request: Request): """ Store a payment receipt from an x402 Worker after successful payment verification. AUTH: Requires X-API-Key header matching ADMIN_API_KEY env var. Called by x402-base and x402-sol Workers after verifyPayment succeeds. Stores receipt in Redis for revenue tracking, audit trail, and transparency dashboard. POST body: { "receipt_id": "rmi--", "payment_tx": "0x...|", "amount": "$0.01"|0.01|10000, "asset": "USDC", "network": "base"|"solana"|, "customer_address": "0x...|", "scan_request": {}, "status": "fulfilled", "tool": "urlcheck"|"audit"|..., "delivery_status": "fulfilled", "fallback_chain": [] } """ # Auth: require admin API key admin_key = os.getenv("ADMIN_API_KEY", os.getenv("WP_ADMIN_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"}) try: body = await request.json() receipt_id = body.get("receipt_id", f"rmi-{int(time.time())}-unknown") payment_tx = body.get("payment_tx", "") amount = body.get("amount", "") asset = body.get("asset", "USDC") network = body.get("network", "unknown") customer_address = body.get("customer_address", "") tool = body.get("tool", "unknown") status = body.get("status", "fulfilled") delivery_status = body.get("delivery_status", "fulfilled") fallback_chain = body.get("fallback_chain", []) scan_request = body.get("scan_request", {}) # Normalize amount if isinstance(amount, str): amount_val = amount.replace("$", "") try: amount_float = float(amount_val) except (ValueError, TypeError): amount_float = 0.0 elif isinstance(amount, (int, float)): amount_float = float(amount) else: amount_float = 0.0 # Store in Redis (sync — app.auth.get_redis returns a sync client) from app.auth import get_redis as _get_redis r = _get_redis() if r: receipt_data = { "receipt_id": receipt_id, "payment_tx": payment_tx, "amount": str(amount_float) if amount_float else str(amount), "asset": asset, "network": network, "customer_address": customer_address, "tool": tool, "status": status, "delivery_status": delivery_status, "timestamp": str(time.time()), "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "fallback_chain": fallback_chain, "scan_request": scan_request, "amount_usd": amount_float, } # Store receipt with 30-day TTL r.set(f"x402:receipt:{receipt_id}", json.dumps(receipt_data), ex=30 * 86400) # Also store by transaction hash for lookup if payment_tx and payment_tx != "local-verified": r.set( f"x402:spent_tx:{payment_tx}", json.dumps( { "chain": network, "payer": customer_address, "amount": str(amount_float), "tool": tool, "timestamp": str(time.time()), "receipt_id": receipt_id, } ), ex=30 * 86400, ) # Add to time-ordered index for ledger queries r.zadd("x402:receipts_by_time", {receipt_id: time.time()}) # Increment revenue counter today = time.strftime("%Y-%m-%d") r.incrbyfloat(f"x402:revenue:daily:{today}", amount_float or 0) r.incrbyfloat("x402:revenue:total", amount_float or 0) r.incrby(f"x402:calls:daily:{today}", 1) r.incrby("x402:calls:total", 1) # Tool-level stats r.incrby(f"x402:tool_calls:{tool}", 1) r.incrbyfloat(f"x402:tool_revenue:{tool}", amount_float or 0) logger.info(f"Receipt stored: {receipt_id} tx={payment_tx[:16]}... tool={tool} amount=${amount_float}") return { "ok": True, "receipt_id": receipt_id, "stored": True, "message": "Receipt stored successfully", } else: # Redis unavailable — still return success so Worker doesn't retry endlessly logger.warning(f"Redis unavailable, receipt not stored: {receipt_id}") return { "ok": True, "receipt_id": receipt_id, "stored": False, "message": "Receipt received but Redis unavailable", } except Exception as e: logger.error(f"Receipt store error: {e}") # Return 200 so Worker doesn't retry — receipt storage is best-effort return { "ok": True, "stored": False, "error": str(e)[:200], "message": "Receipt received but storage failed", } # ── Fallback System Status ──────────────────────────────────── @router.get("/fallback/status") async def fallback_status(): """ Check fallback system health, cache stats, and fallback success rate. Shows which fallback layers are working and which are failing. """ try: from app.auth import get_redis r = await get_redis() # Count cache entries cache_count = 0 async for _key in r.scan_iter(match="x402:cache:*", count=100): cache_count += 1 # Get fallback usage stats fallback_uses = 0 async for _key in r.scan_iter(match="x402:fallback:*", count=100): fallback_uses += 1 return { "fallback_system": "active", "cache_entries": cache_count, "fallback_uses": fallback_uses, "layers_available": [ "cache", "geckoterminal", "dexlab", "step_finance", "moonrank", "web_scrape", "dexscreener_web", "raydium", "orca", "jito", "twitter", "reddit", "news", "web_search", "bigquery", ], "priority_order": [ "1. Primary APIs (DexScreener, RPCs, etc.)", "2. Secondary APIs (Birdeye, Jupiter, Coingecko)", "3. Tertiary APIs (DeFiLlama, GeckoTerminal, DexLab)", "4. Web scraping (Solscan, Etherscan, Basescan HTML)", "5. Browser automation (JS-heavy sites)", "6. AI web search (DuckDuckGo, Google)", "7. Public data archives (BigQuery public datasets)", "8. Community APIs (Jito, Orca, Raydium)", "9. RSS/feeds (crypto news, launch trackers)", "10. Social media (Twitter/X, Reddit public)", "11. Cache layer (previous successful results)", ], "refund_policy": "Refund only if ALL fallback layers fail", } except Exception as e: raise HTTPException(status_code=500, detail=str(e))