rmi-backend/app/routers/x402_dashboard.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

527 lines
19 KiB
Python

"""
RMI x402 Analytics Dashboard Endpoint
=======================================
GET /api/v1/x402/dashboard - Public analytics endpoint returning:
- Total earnings, earnings by chain, earnings by tool, earnings by day
- Trial usage counts (today, all-time, unique fingerprints, unique wallets)
- Top tools by revenue, top tools by usage
- Paid from Redis x402:spent_tx:*, x402:trial:*, x402:global:trials_today, etc.
- Persisted via Supabase x402_payments table
Author: RMI Development
Date: 2026-05-20
"""
import asyncio
import contextlib
import json
import logging
import os
import time
from collections import defaultdict
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, HTTPException
logger = logging.getLogger("x402_dashboard")
router = APIRouter(prefix="/api/v1/x402", tags=["x402 Analytics Dashboard"])
# ── Redis helper (reuse existing pattern from enforcement) ─────────────
def _get_redis():
"""Synchronous Redis client for dashboard queries."""
try:
import redis
r = redis.Redis(
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD", ""),
db=0,
decode_responses=True,
)
r.ping()
return r
except Exception as e:
logger.warning(f"Dashboard: Redis unavailable: {e}")
return None
async def _get_redis_async():
"""Async Redis client for dashboard queries."""
try:
from app.auth import get_redis
r = await get_redis()
return r
except Exception:
return _get_redis()
# ── Supabase payment persistence ────────────────────────────────────
async def _persist_payment_to_supabase(
tool: str, amount_atoms: str, chain: str, payer: str, tx_hash: str, status: str = "fulfilled"
):
"""Insert a payment record into Supabase x402_payments table."""
try:
from app.services.supabase_service import _post
await _post(
"x402_payments",
{
"tool": tool,
"amount_atoms": str(amount_atoms),
"chain": chain,
"payer": payer or "unknown",
"tx_hash": tx_hash or "",
"status": status,
"created_at": datetime.now(UTC).isoformat(),
},
)
logger.info(
f"Persisted x402 payment to Supabase: tool={tool} chain={chain} tx={tx_hash[:16] if tx_hash else 'N/A'}"
)
except Exception as e:
logger.warning(f"Supabase payment persist failed: {e}")
async def _sync_spent_tx_to_supabase():
"""Sync Redis x402:spent_tx:* entries to Supabase (run periodically).
Reads all x402:spent_tx:* keys from Redis and upserts them into x402_payments.
Uses tx_hash as dedup key to avoid duplicates.
"""
r = _get_redis()
if not r:
return 0
synced = 0
try:
from app.services.supabase_service import _get, _post
# Get existing tx_hashes from Supabase to avoid duplicates
existing = await _get("x402_payments", select="tx_hash", limit=10000)
existing_txs = {row["tx_hash"] for row in existing if row.get("tx_hash")}
# Scan Redis for spent_tx entries
cursor = 0
while True:
cursor, keys = r.scan(cursor, match="x402:spent_tx:*", count=200)
for key in keys:
tx_hash = key.replace("x402:spent_tx:", "")
if tx_hash in existing_txs:
continue
try:
data = json.loads(r.get(key))
tool = data.get("tool", "unknown")
amount_atoms = data.get("amount", "0")
chain = data.get("chain", "unknown")
payer = data.get("payer", "unknown")
await _post(
"x402_payments",
{
"tool": tool,
"amount_atoms": str(amount_atoms),
"chain": chain,
"payer": payer,
"tx_hash": tx_hash,
"status": "fulfilled",
"created_at": datetime.fromtimestamp(data.get("timestamp", time.time()), tz=UTC).isoformat()
if data.get("timestamp")
else datetime.now(UTC).isoformat(),
},
)
existing_txs.add(tx_hash)
synced += 1
except Exception as e:
logger.debug(f"Sync failed for {key}: {e}")
if cursor == 0:
break
if synced:
logger.info(f"Synced {synced} x402 payments from Redis to Supabase")
except Exception as e:
logger.warning(f"Redis->Supabase sync error: {e}")
return synced
# ── Dashboard data aggregation from Redis ────────────────────────────
def _aggregate_payment_stats(r) -> dict[str, Any]:
"""Aggregate payment/earnings data from Redis x402:spent_tx:* keys."""
total_earnings_atoms = 0
earnings_by_chain: dict[str, int] = defaultdict(int)
defaultdict(int)
tool_usage: dict[str, int] = defaultdict(int)
tool_revenue: dict[str, int] = defaultdict(int)
unique_payers = set()
all_payments = [] # For daily breakdown
cursor = 0
while True:
cursor, keys = r.scan(cursor, match="x402:spent_tx:*", count=500)
for key in keys:
try:
data = json.loads(r.get(key))
amount = int(data.get("amount", "0") or "0")
chain = data.get("chain", "unknown")
payer = data.get("payer", "unknown")
tool = data.get("tool", "unknown")
timestamp = data.get("timestamp", 0)
total_earnings_atoms += amount
earnings_by_chain[chain] += amount
if tool and tool != "unknown":
tool_usage[tool] += 1
tool_revenue[tool] += amount
if payer and payer != "unknown":
unique_payers.add(payer)
all_payments.append(
{
"amount": amount,
"chain": chain,
"tool": tool,
"payer": payer,
"timestamp": timestamp,
}
)
except Exception:
continue
if cursor == 0:
break
# Also check x402-tool:* keys (from old record_x402_payment) - tool stats only, no double-count
cursor = 0
while True:
cursor, keys = r.scan(cursor, match="x402-tool:*", count=500)
for key in keys:
try:
data = json.loads(r.get(key))
tool = data.get("tool", "unknown")
amount_str = data.get("amount", "0")
# Amount might be in USD format (e.g., "0.05") or atoms
try:
amt = float(amount_str)
# Convert USD to atoms if < 1 (heuristic: USD prices are < 1)
amt_atoms = int(amt * 1000000.0) if amt < 1 else int(amt)
except (ValueError, TypeError):
amt_atoms = 0
tool_usage[tool] += 1
tool_revenue[tool] += amt_atoms
# Don't add to total_earnings_atoms - already counted from spent_tx
customer = data.get("customer", "")
if customer:
unique_payers.add(customer)
chain = data.get("chain", "unknown")
# x402-tool entries may not have chain info
if chain != "unknown":
earnings_by_chain[chain] += amt_atoms
except Exception:
continue
if cursor == 0:
break
# Also check x402:receipt:* keys (from x402_middleware.py)
cursor = 0
while True:
cursor, keys = r.scan(cursor, match="x402:receipt:*", count=500)
for key in keys:
try:
data = json.loads(r.get(key))
tool = data.get("tool", "unknown")
amount_str = str(data.get("amount", "0"))
chain = data.get("network", data.get("chain", "unknown"))
customer = data.get("customer_address", data.get("customer", ""))
try:
amt = float(amount_str)
amt_atoms = int(amt * 1000000.0) if amt < 1 else int(amt)
except (ValueError, TypeError):
amt_atoms = 0
tool_usage[tool] += 1
# Don't double-count totals from receipt keys - spent_tx is canonical
tool_revenue[tool] += amt_atoms
if chain:
pass # Don't double-count chain earnings
if customer:
unique_payers.add(customer)
except Exception:
continue
if cursor == 0:
break
# Earnings by day (from all_payments timestamps)
time.time()
earnings_by_day: dict[str, int] = defaultdict(int)
for p in all_payments:
if p.get("timestamp"):
try:
day = datetime.fromtimestamp(p["timestamp"], tz=UTC).strftime("%Y-%m-%d")
earnings_by_day[day] += p.get("amount", 0)
except Exception:
pass
# Today's earnings
today_str = datetime.now(UTC).strftime("%Y-%m-%d")
today_earnings = earnings_by_day.get(today_str, 0)
# Convert atoms to USDC (6 decimals)
total_earnings_usdc = total_earnings_atoms / 1e6
today_earnings_usdc = today_earnings / 1e6
earnings_by_chain_usdc = {k: v / 1e6 for k, v in earnings_by_chain.items()}
earnings_by_day_usdc = {k: v / 1e6 for k, v in sorted(earnings_by_day.items())[-30:]} # Last 30 days
tool_revenue_usdc = {k: v / 1e6 for k, v in tool_revenue.items()}
# Top tools by revenue
top_tools_revenue = sorted(tool_revenue_usdc.items(), key=lambda x: -x[1])[:10]
# Top tools by usage count
top_tools_usage = sorted(tool_usage.items(), key=lambda x: -x[1])[:10]
return {
"total_earnings_usdc": round(total_earnings_usdc, 6),
"today_earnings_usdc": round(today_earnings_usdc, 6),
"earnings_by_chain": earnings_by_chain_usdc,
"earnings_by_day": earnings_by_day_usdc,
"unique_payers": len(unique_payers),
"top_tools_by_revenue": [{"tool": t, "revenue_usdc": round(r, 6)} for t, r in top_tools_revenue],
"top_tools_by_usage": [{"tool": t, "calls": c} for t, c in top_tools_usage],
}
def _aggregate_trial_stats(r) -> dict[str, Any]:
"""Aggregate trial usage stats from Redis x402:trial:* keys."""
total_trials_all_time = 0
unique_fingerprints = set()
unique_wallets = set()
trials_by_tool: dict[str, int] = defaultdict(int)
cursor = 0
while True:
cursor, keys = r.scan(cursor, match="x402:trial:*", count=500)
for key in keys:
# Key format: x402:trial:{client_id}:{tool_id}
try:
parts = key.replace("x402:trial:", "").split(":", 1)
client_id = parts[0] if parts else "unknown"
tool_id = parts[1] if len(parts) > 1 else "unknown"
val = int(r.get(key) or 0)
total_trials_all_time += val
trials_by_tool[tool_id] += val
# Categorize identity
if client_id.startswith("w:"):
unique_wallets.add(client_id)
elif client_id.startswith("dev:") or client_id.startswith("ts:"):
unique_fingerprints.add(client_id)
else:
unique_fingerprints.add(client_id)
except Exception:
continue
if cursor == 0:
break
# Today's trials
trials_today = 0
with contextlib.suppress(Exception):
trials_today = int(r.get("x402:global:trials_today") or 0)
# IP daily stats
cursor = 0
ip_daily_total = 0
while True:
cursor, keys = r.scan(cursor, match="x402:ip_daily:*", count=500)
for key in keys:
with contextlib.suppress(Exception):
ip_daily_total += int(r.get(key) or 0)
if cursor == 0:
break
return {
"trials_today": trials_today,
"trials_all_time": total_trials_all_time,
"unique_fingerprint_ids": len(unique_fingerprints),
"unique_wallet_ids": len(unique_wallets),
"trials_by_tool": dict(sorted(trials_by_tool.items(), key=lambda x: -x[1])[:20]),
"ip_daily_free_calls": ip_daily_total,
}
# ── Dashboard endpoint ──────────────────────────────────────────────
@router.get("/dashboard")
async def x402_dashboard():
"""
Public analytics dashboard for x402 payment and trial stats.
Returns:
- Total earnings (USDC), today's earnings
- Earnings breakdown by chain, tool, day
- Trial stats: today, all-time, unique fingerprints/wallets
- Top tools by revenue and usage count
- System health metadata
"""
try:
r = _get_redis()
if r:
payment_stats = _aggregate_payment_stats(r)
trial_stats = _aggregate_trial_stats(r)
else:
payment_stats = {
"total_earnings_usdc": 0,
"today_earnings_usdc": 0,
"earnings_by_chain": {},
"earnings_by_day": {},
"unique_payers": 0,
"top_tools_by_revenue": [],
"top_tools_by_usage": [],
}
trial_stats = {
"trials_today": 0,
"trials_all_time": 0,
"unique_fingerprint_ids": 0,
"unique_wallet_ids": 0,
"trials_by_tool": {},
"ip_daily_free_calls": 0,
}
# Try to get Supabase data for enrichment
supabase_count = 0
supabase_total = 0.0
supabase_summary = {}
try:
from app.services.supabase_service import _get, get_x402_earnings_summary
rows = await _get("x402_payments", select="id,amount_atoms,chain,tool,status", limit=5000)
supabase_count = len(rows)
for row in rows:
with contextlib.suppress(ValueError, TypeError):
supabase_total += int(row.get("amount_atoms", "0") or "0") / 1e6
# Get full Supabase earnings summary
supabase_summary = await get_x402_earnings_summary()
except Exception as e:
logger.debug(f"Supabase dashboard enrichment failed: {e}")
# Trigger background sync (non-blocking)
with contextlib.suppress(Exception):
asyncio.create_task(_sync_spent_tx_to_supabase())
return {
"earnings": payment_stats,
"trials": trial_stats,
"supabase": {
"persisted_count": supabase_count,
"total_usdc": round(supabase_total, 6),
"earnings_summary": supabase_summary,
},
"meta": {
"timestamp": datetime.now(UTC).isoformat(),
"redis_available": r is not None,
"chains_supported": [
"base",
"solana",
"ethereum",
"bsc",
"arbitrum",
"optimism",
"polygon",
],
},
}
except Exception as e:
logger.error(f"Dashboard error: {e}")
raise HTTPException(status_code=500, detail=f"Dashboard error: {str(e)[:200]}") from e
# ── Supabase table creation helper ──────────────────────────────────
async def ensure_x402_payments_table():
"""Ensure x402_payments table exists in Supabase.
Delegates to supabase_service.ensure_x402_payments_table()
which uses the working exec_sql RPC function.
"""
try:
from app.services.supabase_service import ensure_x402_payments_table as _ensure
result = await _ensure()
if result:
logger.info("x402_payments table ensured in Supabase")
else:
logger.warning("x402_payments table creation may have failed")
return result
except ImportError:
# Fallback: direct call via REST
try:
from app.services.supabase_service import _get_key, _get_url
SK = _get_key()
SU = _get_url()
sql = """
DO $$ BEGIN
CREATE TABLE IF NOT EXISTS x402_payments (
id BIGSERIAL PRIMARY KEY,
tool TEXT NOT NULL DEFAULT 'unknown',
amount_atoms TEXT NOT NULL DEFAULT '0',
chain TEXT NOT NULL DEFAULT 'unknown',
payer TEXT NOT NULL DEFAULT 'unknown',
tx_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status TEXT NOT NULL DEFAULT 'fulfilled'
);
END $$;
CREATE INDEX IF NOT EXISTS idx_x402_payments_tool ON x402_payments(tool);
CREATE INDEX IF NOT EXISTS idx_x402_payments_chain ON x402_payments(chain);
CREATE INDEX IF NOT EXISTS idx_x402_payments_tx_hash ON x402_payments(tx_hash);
CREATE INDEX IF NOT EXISTS idx_x402_payments_created_at ON x402_payments(created_at);
CREATE INDEX IF NOT EXISTS idx_x402_payments_payer ON x402_payments(payer);
-- RLS: service_role only
ALTER TABLE x402_payments ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
DROP POLICY IF EXISTS "Service role full access" ON x402_payments;
EXCEPTION WHEN OTHERS THEN NULL; END $$;
CREATE POLICY "Service role full access" ON x402_payments FOR ALL TO service_role USING (true) WITH CHECK (true);
"""
import httpx
headers = {
"apikey": SK,
"Authorization": f"Bearer {SK}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(
f"{SU}/rest/v1/rpc/exec_sql",
json={"sql": sql},
headers=headers,
)
logger.info(f"x402_payments table creation via exec_sql: {r.status_code}")
return r.status_code in (200, 204)
except Exception as e:
logger.warning(f"Could not ensure x402_payments table (fallback): {e}")
return False
except Exception as e:
logger.warning(f"Could not ensure x402_payments table: {e}")
return False
# ── Register on app startup ─────────────────────────────────────────
async def on_startup():
"""Call this on app startup to ensure the table exists."""
await ensure_x402_payments_table()