rmi-backend/app/routers/x402_middleware.py

857 lines
31 KiB
Python

"""
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": "<x402 payment payload JSON string>",
"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-<timestamp>-<rand>",
"payment_tx": "0x...|<sol-sig>",
"amount": "$0.01"|0.01|10000,
"asset": "USDC",
"network": "base"|"solana"|<chain>,
"customer_address": "0x...|<sol-addr>",
"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))