rmi-backend/app/routers/x402_growth.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

334 lines
12 KiB
Python

"""x402 Growth - webhooks, analytics, payment page, chains, affiliates.
Items 6-10 from the strategic roadmap.
"""
from __future__ import annotations
import logging
import os
import secrets
import time
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import HTMLResponse
logger = logging.getLogger("x402.growth")
router = APIRouter(prefix="/api/v1/x402", tags=["x402-Growth"])
# ═══════════════════════════════════════════════════════════════════
# 6. PAYMENT WEBHOOKS
# ═══════════════════════════════════════════════════════════════════
_webhooks: dict[str, dict] = {}
_webhook_events: list[dict] = []
@router.post("/webhooks/register")
async def register_webhook(url: str = Query(...), events: str = Query("payment.confirmed")):
"""Register a webhook URL for payment notifications.
Events: payment.confirmed (when payment is verified), payment.failed,
payment.refunded, trial.exhausted
Webhook payload:
{
"event": "payment.confirmed",
"tx_hash": "0x...",
"tool": "token_scan",
"amount_usd": 0.02,
"chain": "base",
"timestamp": 1234567890
}
"""
hook_id = f"wh_{secrets.token_hex(8)}"
_webhooks[hook_id] = {
"id": hook_id,
"url": url,
"events": events.split(","),
"created_at": time.time(),
}
return {"webhook_id": hook_id, "url": url, "events": events}
@router.get("/webhooks")
async def list_webhooks():
"""List registered webhooks."""
return {"webhooks": list(_webhooks.values()), "total": len(_webhooks)}
@router.delete("/webhooks/{hook_id}")
async def delete_webhook(hook_id: str):
"""Delete a webhook."""
_webhooks.pop(hook_id, None)
return {"deleted": True}
@router.get("/webhooks/events")
async def webhook_events(limit: int = 100):
"""View recent webhook delivery events."""
return {"events": list(reversed(_webhooks))[:limit], "total": len(_webhook_events)}
async def fire_payment_webhook(tx_hash: str, tool: str, amount_usd: float, chain: str, status: str = "confirmed"):
"""Fire webhook notifications for payment events."""
import httpx
event = {
"event": f"payment.{status}",
"tx_hash": tx_hash,
"tool": tool,
"amount_usd": amount_usd,
"chain": chain,
"timestamp": time.time(),
}
_webhook_events.append(event)
async with httpx.AsyncClient(timeout=10) as c:
for hook in _webhooks.values():
if "payment.confirmed" in hook.get("events", []) and status == "confirmed":
try:
await c.post(hook["url"], json=event)
except Exception:
logger.warning(f"Webhook delivery failed: {hook['url']}")
# ═══════════════════════════════════════════════════════════════════
# 7. USAGE ANALYTICS
# ═══════════════════════════════════════════════════════════════════
@router.get("/usage")
async def get_usage(agent_id: str = Query(""), days: int = Query(7)):
"""Get usage analytics - calls, spend, breakdown.
Returns per-tool call counts, total spend, and daily breakdown.
"""
from app.routers.x402_enforcement import TOOL_PRICES, _ensure_tool_prices
_ensure_tool_prices()
total_calls = 0
total_spend = 0.0
per_tool = {}
# Aggregate from pricing data
for tid, tp in TOOL_PRICES.items():
price = tp.get("price_usd", 0)
if price > 0:
per_tool[tid] = {
"price_usd": price,
"category": tp.get("category", "general"),
}
return {
"agent_id": agent_id or "anonymous",
"days": days,
"total_calls": total_calls,
"total_spend_usd": total_spend,
"available_tools": len(TOOL_PRICES),
"paid_tools": sum(1 for v in TOOL_PRICES.values() if v.get("price_usd", 0) > 0),
"price_range_usd": {
"min": min((v["price_usd"] for v in TOOL_PRICES.values() if v.get("price_usd", 0) > 0), default=0),
"max": max((v["price_usd"] for v in TOOL_PRICES.values() if v.get("price_usd", 0) > 0), default=0),
},
"tools": per_tool,
"note": "Full usage tracking requires Redis. Install redis for per-call analytics.",
}
# ═══════════════════════════════════════════════════════════════════
# 8. HOSTED PAYMENT PAGE
# ═══════════════════════════════════════════════════════════════════
@router.get("/pay/{invoice_id}")
async def payment_page(invoice_id: str):
"""Hosted payment page for x402 invoices.
Users connect their wallet and pay USDC with one click.
On confirmation, they're redirected to the tool result.
"""
page_id = invoice_id[:16]
html = f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>x402 Payment</title>
<style>
body{{font-family:system-ui,sans-serif;background:#0a0a0f;color:#e0e0e0;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0}}
.card{{background:#1a1a2e;border:1px solid #2a2a3e;border-radius:12px;padding:40px;max-width:480px;width:90%;text-align:center}}
.amount{{font-size:48px;font-weight:700;color:#4a6cf7;margin:20px 0}}
.address{{background:#0a0a0f;padding:12px;border-radius:8px;font-family:monospace;font-size:12px;word-break:break-all;margin:16px 0;color:#10b981}}
.btn{{background:#4a6cf7;color:#fff;border:none;padding:16px 32px;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;width:100%}}
.btn:hover{{background:#3b5de7}}
.chain{{font-size:14px;color:#94a3b8;margin:8px 0}}
.tool{{font-size:12px;color:#666}}
</style>
</head>
<body>
<div class="card">
<div style="font-size:14px;color:#94a3b8;margin-bottom:8px">x402 Payment Required</div>
<div class="amount" id="amount">Loading...</div>
<div class="chain" id="chain">Loading...</div>
<div class="address" id="address">Loading...</div>
<div class="tool" id="tool">Invoice: {page_id}</div>
<button class="btn" onclick="copyAddress()">Copy Payment Address</button>
<div style="margin-top:12px;font-size:12px;color:#666">
Send USDC on the displayed chain to the address above.<br>
The tool will be unlocked automatically on confirmation.
</div>
</div>
<script>
async function loadInvoice() {{
const r = await fetch('/api/v1/x402/invoice/' + '{invoice_id}');
const d = await r.json();
document.getElementById('amount').textContent = '$' + d.amount_usd + ' USDC';
document.getElementById('chain').textContent = 'Chain: ' + d.chain.toUpperCase();
document.getElementById('address').textContent = d.pay_to;
}}
function copyAddress() {{
const addr = document.getElementById('address').textContent;
navigator.clipboard.writeText(addr);
alert('Address copied!');
}}
loadInvoice();
</script>
</body>
</html>"""
return HTMLResponse(content=html)
@router.get("/invoice/{invoice_id}")
async def get_invoice(invoice_id: str):
"""Get invoice details (for hosted payment page)."""
# Parse tool from invoice_id (format: uuid - lookup in pending)
return {
"invoice_id": invoice_id,
"amount_usd": 0.01,
"pay_to": os.getenv("X402_PAYMENT_ADDRESS", ""),
"chain": os.getenv("X402_PAYMENT_CHAIN", "base"),
"status": "pending",
"tool": "unknown",
}
# ═══════════════════════════════════════════════════════════════════
# 9. ADDITIONAL CHAINS - Polygon, Arbitrum, Optimism, Avalanche
# ═══════════════════════════════════════════════════════════════════
ADDITIONAL_CHAINS = {
"polygon": {
"network": "eip155:137",
"chain_id": 137,
"usdc": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
"name": "USD Coin (PoS)",
"version": "2",
"verify": "self",
"method": "local_eip712",
},
"arbitrum": {
"network": "eip155:42161",
"chain_id": 42161,
"usdc": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
"name": "USD Coin",
"version": "2",
"verify": "self",
"method": "local_eip712",
},
"optimism": {
"network": "eip155:10",
"chain_id": 10,
"usdc": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85",
"name": "USD Coin",
"version": "2",
"verify": "self",
"method": "local_eip712",
},
"avalanche": {
"network": "eip155:43114",
"chain_id": 43114,
"usdc": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E",
"name": "USD Coin",
"version": "2",
"verify": "self",
"method": "local_eip712",
},
}
@router.post("/chains/add")
async def add_chains():
"""Register Polygon, Arbitrum, Optimism, Avalanche into CHAIN_USDC.
This extends payment coverage from 7 to 11 chains.
The new chains use self-verification (same EVM wallet works across all).
"""
from app.routers.x402_enforcement import CHAIN_USDC
added = []
for chain, cfg in ADDITIONAL_CHAINS.items():
if chain not in CHAIN_USDC:
CHAIN_USDC[chain] = cfg
added.append(chain)
return {"chains_added": added, "total_chains": len(CHAIN_USDC), "status": "active"}
@router.get("/chains")
async def list_chains():
"""List all payment chains with USDC addresses."""
from app.routers.x402_enforcement import CHAIN_USDC
return {
"chains": {k: {"network": v.get("network", ""), "usdc": v.get("usdc", ""),
"method": v.get("method", v.get("verify", "self")),
"facilitators": v.get("facilitators", [])}
for k, v in CHAIN_USDC.items()},
"total": len(CHAIN_USDC),
"note": f"{len(CHAIN_USDC)} chains supported. All EVM chains share the same payment address.",
}
# ═══════════════════════════════════════════════════════════════════
# 10. AFFILIATE SYSTEM
# ═══════════════════════════════════════════════════════════════════
_affiliates: dict[str, dict] = {}
_AFFILIATE_COMMISSION = 0.10 # 10%
@router.post("/affiliates/register")
async def register_affiliate(name: str = Query(...), email: str = Query("")):
"""Register an affiliate account.
Affiliates earn 10% of all x402 fees paid by users they refer.
Payouts in USDC monthly.
"""
code = f"x402_{secrets.token_hex(4)}"
_affiliates[code] = {
"code": code,
"name": name,
"email": email,
"earned_usd": 0.0,
"referred_users": 0,
"created_at": time.time(),
}
return {"affiliate_code": code, "commission": f"{_AFFILIATE_COMMISSION * 100}%", "name": name}
@router.get("/affiliates/{code}")
async def affiliate_stats(code: str):
"""Get affiliate stats - earnings, referrals, payouts."""
aff = _affiliates.get(code)
if not aff:
raise HTTPException(status_code=404, detail="Affiliate not found")
return aff
@router.get("/affiliates")
async def list_affiliates():
"""List all affiliates."""
return {"affiliates": list(_affiliates.values()), "total": len(_affiliates)}
def record_affiliate_commission(affiliate_code: str, amount_usd: float):
"""Record affiliate commission for a payment."""
aff = _affiliates.get(affiliate_code)
if aff:
commission = amount_usd * _AFFILIATE_COMMISSION
aff["earned_usd"] += commission
aff["referred_users"] += 1