"""T34 x402 Paid Tools Catalog. Per v4.0 §T34. Endpoints: GET /api/v1/x402/catalog — list all tools with pricing tiers GET /api/v1/x402/usage — per-agent usage stats GET /api/v1/x402/receipts/{tx_hash} — payment receipt POST /api/v1/x402/verify — verify a payment header """ from __future__ import annotations import logging from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field from app.catalog.service import get_catalog from app.domain.x402.middleware import ( PRICING, get_tool_metadata, get_tool_price_usd, verify_payment_header, verify_payment_on_chain, ) router = APIRouter(prefix="/api/v1/x402", tags=["x402"]) class CatalogResponse(BaseModel): server: str = "rugmunch-intelligence" version: str = "4.0" tools: list[dict[str, Any]] = Field(default_factory=list) payment_protocol: str = "x402" class UsageResponse(BaseModel): agent_id: str daily_calls: int free_limit: int paid_calls: int total_usd: float recent_receipts: list[dict[str, Any]] = Field(default_factory=list) class ReceiptResponse(BaseModel): tx_hash: str tool: str agent_id: str | None = None amount_usd: float chain: str | None = None paid_at: str | None = None tier: str | None = None @router.get("/catalog", response_model=CatalogResponse) async def catalog() -> CatalogResponse: """List all paid tools with pricing tiers.""" return CatalogResponse(tools=[get_tool_metadata(t) for t in PRICING]) @router.get("/usage", response_model=UsageResponse) async def usage(request: Request) -> UsageResponse: """Per-agent usage stats: calls made, $ spent, top tools.""" catalog = get_catalog() await catalog._init_stores() agent_id = request.headers.get("X-Agent-Id") or request.headers.get("X-Api-Key") or "anon" daily_calls = 0 paid_calls = 0 total_usd = 0.0 recent: list[dict] = [] if catalog._health.redis: try: from datetime import UTC, datetime day = datetime.now(UTC).strftime("%Y%m%d") key = f"x402:rl:{agent_id}:{day}" daily_calls = int(await catalog._redis.get(key) or 0) except Exception: pass if catalog._health.postgres: try: async with catalog._pg_pool.acquire() as conn: rows = await conn.fetch( "SELECT tx_hash, tool, amount_usd, paid_at FROM x402_receipts " "WHERE agent_id = $1 ORDER BY paid_at DESC LIMIT 10", agent_id, ) for r in rows: paid_calls += 1 total_usd += float(r["amount_usd"] or 0) recent.append({ "tx_hash": r["tx_hash"], "tool": r["tool"], "amount_usd": float(r["amount_usd"] or 0), "paid_at": r["paid_at"].isoformat() if r["paid_at"] else None, }) except Exception as e: logging.getLogger(__name__).warning(f"usage_query_fail: {e}") return UsageResponse( agent_id=agent_id, daily_calls=daily_calls, free_limit=5, paid_calls=paid_calls, total_usd=round(total_usd, 4), recent_receipts=recent, ) @router.get("/receipts/{tx_hash}", response_model=ReceiptResponse) async def receipt(tx_hash: str) -> ReceiptResponse: """Payment receipt for a specific on-chain tx.""" catalog = get_catalog() await catalog._init_stores() if not catalog._health.postgres: raise HTTPException(503, "postgres unavailable") try: async with catalog._pg_pool.acquire() as conn: r = await conn.fetchrow( "SELECT * FROM x402_receipts WHERE tx_hash=$1", tx_hash ) if not r: raise HTTPException(404, "receipt not found") d = dict(r) return ReceiptResponse( tx_hash=d["tx_hash"], tool=d["tool"], agent_id=d.get("agent_id"), amount_usd=float(d.get("amount_usd", 0)), chain=d.get("chain"), paid_at=d["paid_at"].isoformat() if d.get("paid_at") else None, tier=d.get("tier"), ) except HTTPException: raise except Exception as e: raise HTTPException(500, f"receipt_query_fail: {e}") @router.post("/verify") async def verify_payment(request: Request) -> dict: """Verify an X-Payment header for a given tool.""" body = await request.json() tool = body.get("tool", "") x_payment = body.get("x_payment", "") if tool not in PRICING: raise HTTPException(404, f"unknown tool: {tool}") if get_tool_price_usd(tool) == 0: return {"valid": True, "tier": "free", "amount_usd": 0} decoded = verify_payment_header(x_payment, get_tool_price_usd(tool)) if not decoded: return {"valid": False, "reason": "invalid format"} tx_hash, _ = decoded valid = await verify_payment_on_chain(tx_hash, get_tool_price_usd(tool)) return { "valid": valid, "tool": tool, "amount_usd": get_tool_price_usd(tool), "tx_hash": tx_hash, }