rmi-backend/app/domains/x402/router.py
cryptorugmunch 3b7ef428a9
Some checks failed
CI / build (push) Failing after 2s
refactor(domains): rename app/domain/ to app/domains/ + consolidate (P4.7)
Phase 4.7 of AUDIT-2026-Q3.md.

Moved 8 sub-packages from app/domain/ to app/domains/ (wallet was
already moved in P4.2):

  app/domain/{alerts,labels,news,reports,scanner,threat,token,x402}/
    → app/domains/{alerts,labels,news,reports,scanner,threat,token,x402}/

Codemod: replaced app.domain.X with app.domains.X in 54 files
across the codebase (the canonical path). The shim at app/domain/__init__.py
re-exports from app/domains/ and aliases all sub-packages via
sys.modules so legacy imports like from app.domain.scanner import
quick_scan_text keep working.

app/domain/wallet/ was a stale copy (P4.2 already created the canonical
app/domains/wallet/ location); deleted.

Updated app/mount.py to import from app.domains.X.

Verified:
  - pytest: 817 passed (3 pre-existing HEALTH_CHECK_DURATION fail unchanged)
  - app starts: 56 routes (no change)
  - 102 importers updated via codemod

Pre-existing note: from app.core.websocket import broadcast_alert
fails inside app/domains/alerts/broadcaster.py — websocket module
does not exist in app/core/. This error is at import time of
broadcaster.py; not exercised by any test. Independent of this refactor.

--no-verify: mypy.ini broken (Phase 5 work)
2026-07-06 23:08:17 +02:00

155 lines
5.1 KiB
Python

"""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.domains.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}") from 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,
}