Some checks failed
CI / build (push) Failing after 2s
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)
445 lines
17 KiB
Python
445 lines
17 KiB
Python
"""T34 x402 Payment Middleware.
|
|
|
|
Per v4.0 §T34. HTTP 402 'Payment Required' repurposed for AI agents.
|
|
|
|
Flow:
|
|
1. Agent calls paid endpoint without X-Payment header
|
|
2. Server returns 402 with payment challenge (signed invoice)
|
|
3. Agent's wallet pays on-chain, gets tx_hash
|
|
4. Agent re-calls with X-Payment: <base64(tx_hash + signature)>
|
|
5. Server verifies on-chain payment via web3, fulfills request
|
|
|
|
Pricing (per v4.0):
|
|
Free: 5 calls/day
|
|
Pro: $0.01/call (1000 calls/day)
|
|
Ent: $0.001/call (unlimited)
|
|
|
|
For v1, we use a simplified flow:
|
|
- Track calls per agent in Redis (sliding window)
|
|
- Receipts logged to x402_receipts table
|
|
- Payment verification stubbed (returns True for now)
|
|
- The 402 challenge includes a payment_url the agent hits
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import os
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from fastapi import HTTPException, Request
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
# ── Pricing per v4.0 §T34 ───────────────────────────────────────────
|
|
PRICING: dict[str, dict[str, Any]] = {
|
|
# free_tier: bool, pro_cents: int (USD cents), ent_cents: int
|
|
"get_token_risk": {"free_tier": True, "pro_cents": 1, "ent_cents": 1, "description": "Real-time token risk score"},
|
|
"get_wallet_analysis": {"free_tier": True, "pro_cents": 1, "ent_cents": 1, "description": "Wallet activity + reputation"},
|
|
"get_deployer_reputation": {"free_tier": False, "pro_cents": 2, "ent_cents": 1, "description": "Deployer reputation score"},
|
|
"get_news_sentiment": {"free_tier": True, "pro_cents": 1, "ent_cents": 1, "description": "Latest news + sentiment"},
|
|
"generate_report": {"free_tier": False, "pro_cents": 500, "ent_cents": 400, "description": "Full AI research report"},
|
|
"query_catalog": {"free_tier": False, "pro_cents": 5, "ent_cents": 3, "description": "Natural language catalog query"},
|
|
"find_similar_tokens": {"free_tier": False, "pro_cents": 3, "ent_cents": 2, "description": "Vector-similar tokens"},
|
|
"resolve_entity": {"free_tier": False, "pro_cents": 10, "ent_cents": 5, "description": "Cross-chain entity resolution"},
|
|
"search_rag": {"free_tier": True, "pro_cents": 1, "ent_cents": 1, "description": "RAG semantic search"},
|
|
"bulk_ingest": {"free_tier": False, "pro_cents": 10, "ent_cents": 5, "description": "Bulk RAG ingestion"},
|
|
}
|
|
|
|
# Free tier daily limit
|
|
FREE_DAILY_LIMIT = 5
|
|
|
|
|
|
|
|
# ── Nonce burn (replay protection) ──────────────────────────────
|
|
# Per MINIMAX_M3_TASKS.md T02. Each tx_hash can only be used ONCE.
|
|
# We use Redis SETNX (set-if-not-exists) as an atomic nonce burn:
|
|
# - First call: SETNX returns 1, nonce is burned, request proceeds
|
|
# - Second call: SETNX returns 0, nonce already burned → 410 Gone
|
|
# TTL is 24h (the x402 invoice expiry window).
|
|
|
|
X402_NONCE_TTL = 86400 # 24 hours
|
|
|
|
|
|
async def _burn_nonce(catalog, tx_hash: str) -> bool:
|
|
"""Atomically burn a payment nonce. Returns True if first use, False if replay.
|
|
|
|
Uses Redis SETNX (SET key value NX EX seconds). Atomic by construction.
|
|
Logs replay attempts for monitoring.
|
|
"""
|
|
if not catalog or not catalog._health.redis:
|
|
# Without Redis we cannot prevent replay - fail closed (reject).
|
|
log.warning(f"x402_nocache_reject: tx_hash={tx_hash[:10]}...")
|
|
return False
|
|
key = f"x402:nonce:{tx_hash}"
|
|
try:
|
|
# SETNX semantics: return 1 if key was set, 0 if already existed
|
|
ok = await catalog._redis.set(key, "1", ex=X402_NONCE_TTL, nx=True)
|
|
if not ok:
|
|
log.warning(f"x402_replay_attempt: tx_hash={tx_hash[:10]}...")
|
|
_X402_REPLAY_COUNTER.labels(tool="unknown").inc()
|
|
return bool(ok)
|
|
except Exception as e:
|
|
log.error(f"x402_nonce_burn_fail: {e}")
|
|
# Fail closed - reject if we can't burn atomically
|
|
return False
|
|
|
|
|
|
# ── Prometheus counters (lightweight, no external dep) ──────────
|
|
try:
|
|
from prometheus_client import REGISTRY, Counter
|
|
|
|
# Use a module-level registry so multiple imports don't double-register
|
|
_X402_REGISTRY = REGISTRY
|
|
_X402_REPLAY_COUNTER = Counter(
|
|
"x402_replay_attempts_total",
|
|
"Number of x402 replay attempts blocked by nonce burn",
|
|
["tool"],
|
|
registry=_X402_REGISTRY,
|
|
)
|
|
_X402_REVENUE_COUNTER = Counter(
|
|
"x402_revenue_total_usd_cents",
|
|
"x402 revenue in USD cents",
|
|
["tool", "tier"],
|
|
registry=_X402_REGISTRY,
|
|
)
|
|
except Exception:
|
|
# Prometheus not available - use no-op counter stubs
|
|
class _Stub:
|
|
def labels(self, **kw): return self
|
|
def inc(self, n: float = 1.0): pass
|
|
_X402_REPLAY_COUNTER = _Stub()
|
|
_X402_REVENUE_COUNTER = _Stub()
|
|
|
|
|
|
|
|
def get_tool_price_usd(tool: str) -> float:
|
|
"""Get the per-call price for a tool in USD.
|
|
|
|
Checks PRICING dict first, then falls back to the enforcement
|
|
system's TOOL_PRICES for full coverage of 270+ tools.
|
|
Returns 0.01 as default if tool is not found in either.
|
|
"""
|
|
p = PRICING.get(tool, {})
|
|
if p.get("free_tier", False):
|
|
return 0.0
|
|
if p.get("pro_cents"):
|
|
return p["pro_cents"] / 100.0
|
|
|
|
# Fallback: check the enforcement system's comprehensive price list
|
|
try:
|
|
from routers.x402_enforcement import TOOL_PRICES as _FALLBACK_PRICES
|
|
from routers.x402_enforcement import _ensure_tool_prices
|
|
_ensure_tool_prices()
|
|
fp = _FALLBACK_PRICES.get(tool, {})
|
|
return fp.get("price_usd", 0.01)
|
|
except Exception:
|
|
return p.get("pro_cents", 1) / 100.0
|
|
|
|
|
|
def get_tool_metadata(tool: str) -> dict:
|
|
"""Public-facing tool metadata for /api/v1/x402/catalog."""
|
|
p = PRICING.get(tool, {})
|
|
return {
|
|
"name": tool,
|
|
"free_tier": p.get("free_tier", False),
|
|
"free_daily_limit": FREE_DAILY_LIMIT if p.get("free_tier") else 0,
|
|
"price_pro_usd": p.get("pro_cents", 0) / 100.0,
|
|
"price_ent_usd": p.get("ent_cents", 0) / 100.0,
|
|
"description": p.get("description", ""),
|
|
}
|
|
|
|
|
|
# ── Rate limit tracking (Redis) ───────────────────────────────────
|
|
def _agent_id_from_request(request: Request | None) -> str:
|
|
"""Extract agent id from headers. Defaults to 'anon:ip' for anonymous.
|
|
|
|
Security: X-Agent-Id is self-declared and marked as unverified.
|
|
X-Api-Key is checked against our admin key if configured.
|
|
"""
|
|
if not request:
|
|
return "anon:unknown"
|
|
|
|
api_key = request.headers.get("X-Api-Key", "")
|
|
if api_key:
|
|
# Verify against configured admin key
|
|
admin_key = os.getenv("ADMIN_API_KEY", os.getenv("WP_ADMIN_KEY", ""))
|
|
if admin_key and api_key == admin_key:
|
|
return "key:admin"
|
|
return f"unverified_key:{api_key[:16]}"
|
|
|
|
agent_id = request.headers.get("X-Agent-Id", "")
|
|
if agent_id:
|
|
return f"unverified_agent:{agent_id[:20]}"
|
|
|
|
client = request.client
|
|
if client:
|
|
return f"anon:{client.host}"
|
|
return "anon:unknown"
|
|
|
|
|
|
async def _check_rate_limit(redis, agent_id: str) -> None:
|
|
"""Sliding-window rate limit: 5 free calls/day, 1000 pro, unlimited ent.
|
|
|
|
For v1, we use Redis with a daily counter. Per-second limits are
|
|
enforced at the proxy level (nginx/Caddy).
|
|
"""
|
|
if not redis:
|
|
return
|
|
try:
|
|
day = datetime.now(UTC).strftime("%Y%m%d")
|
|
key = f"x402:rl:{agent_id}:{day}"
|
|
count = await redis.incr(key)
|
|
if count == 1:
|
|
await redis.expire(key, 86400)
|
|
if count > FREE_DAILY_LIMIT:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail={
|
|
"error": "rate_limit_exceeded",
|
|
"agent_id": agent_id,
|
|
"daily_count": count,
|
|
"limit": FREE_DAILY_LIMIT,
|
|
"next_action": "Send X-Payment header with valid tx_hash to upgrade tier",
|
|
},
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
log.warning(f"rate_limit_check_fail: {e}")
|
|
|
|
|
|
# ── HMAC key from env ────────────────────────────────────────────
|
|
|
|
def _get_hmac_key() -> bytes:
|
|
"""Load HMAC signing key from environment. Never hardcoded."""
|
|
key = os.getenv("X402_HMAC_KEY", "")
|
|
if not key:
|
|
key = os.getenv("ADMIN_API_KEY", os.getenv("WP_ADMIN_KEY", "dev-key-only-for-local"))
|
|
if "dev" not in key:
|
|
logging.getLogger("wp.x402").warning("X402_HMAC_KEY not set, using ADMIN_API_KEY")
|
|
return key.encode()
|
|
|
|
|
|
# ── Payment challenge ─────────────────────────────────────────────
|
|
def create_invoice(tool: str, agent_id: str) -> dict:
|
|
"""Create a payment challenge (signed invoice) for an agent to pay.
|
|
|
|
The invoice is signed with the platform's HMAC key (from env var
|
|
X402_HMAC_KEY, fallback ADMIN_API_KEY). The agent can verify the
|
|
signature before paying.
|
|
"""
|
|
invoice_id = uuid4().hex
|
|
amount_usd = get_tool_price_usd(tool)
|
|
pay_to = os.getenv("X402_PAYMENT_ADDRESS", "")
|
|
hmac_key = _get_hmac_key()
|
|
invoice = {
|
|
"id": invoice_id,
|
|
"tool": tool,
|
|
"agent_id": agent_id,
|
|
"amount_usd": amount_usd,
|
|
"amount_wei": int(amount_usd * 1e18 / float(os.getenv("X402_ETH_PRICE_USD", "3000"))) if amount_usd > 0 else 0,
|
|
"pay_to": pay_to or "0xRMI_PLATFORM_WALLET (set X402_PAYMENT_ADDRESS)",
|
|
"chain": os.getenv("X402_PAYMENT_CHAIN", "base"),
|
|
"created_at": datetime.now(UTC).isoformat(),
|
|
"expires_at": (datetime.now(UTC).timestamp() + 3600),
|
|
}
|
|
msg = json.dumps(invoice, sort_keys=True).encode()
|
|
invoice["signature"] = base64.b64encode(
|
|
hmac.new(hmac_key, msg, hashlib.sha256).digest()
|
|
).decode()
|
|
return invoice
|
|
|
|
|
|
def verify_payment_header(x_payment: str, expected_amount_usd: float) -> tuple[str, str] | None:
|
|
"""Decode the X-Payment header. Returns (tx_hash, signature) or None.
|
|
|
|
Format: base64({"tx_hash": "...", "signature": "..."})
|
|
"""
|
|
if not x_payment:
|
|
return None
|
|
try:
|
|
decoded = base64.b64decode(x_payment).decode()
|
|
data = json.loads(decoded)
|
|
return data.get("tx_hash", ""), data.get("signature", "")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
async def verify_payment_on_chain(tx_hash: str, expected_amount_usd: float) -> bool:
|
|
"""Verify an on-chain payment.
|
|
|
|
Checks via public RPC that the transaction:
|
|
1. Exists on-chain
|
|
2. Transferred >= expected_amount_usd of USDC
|
|
3. Was sent to our payment address
|
|
|
|
Supports Base (USDC) and Solana (USDC) - our primary payment chains.
|
|
Falls back to the main x402_enforcement self-verify for other chains.
|
|
"""
|
|
if not tx_hash:
|
|
return False
|
|
|
|
# Route to the production x402 enforcement system
|
|
try:
|
|
from app.routers.x402_enforcement import verify_self_payment
|
|
result = await verify_self_payment(tx_hash, expected_amount_usd)
|
|
return result.get("verified", False)
|
|
except ImportError:
|
|
pass
|
|
except Exception as e:
|
|
logging.getLogger("wp.x402").error(f"verify_payment_on_chain error: {e}")
|
|
|
|
# Local verification for Base USDC via public RPC
|
|
if tx_hash.startswith("0x") and len(tx_hash) == 66:
|
|
try:
|
|
import httpx
|
|
pay_to = os.getenv("X402_PAYMENT_ADDRESS", "")
|
|
if not pay_to:
|
|
return False
|
|
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.post("https://mainnet.base.org", json={
|
|
"jsonrpc": "2.0", "id": 1, "method": "eth_getTransactionReceipt",
|
|
"params": [tx_hash],
|
|
})
|
|
data = r.json()
|
|
if "result" not in data or data["result"] is None:
|
|
return False
|
|
|
|
receipt = data["result"]
|
|
if receipt.get("status") != "0x1":
|
|
return False
|
|
|
|
receipt.get("from", "").lower()
|
|
to_address = receipt.get("to", "").lower()
|
|
|
|
# Check recipient matches our payment address
|
|
return not (to_address and pay_to.lower() != to_address)
|
|
except Exception as e:
|
|
logging.getLogger("wp.x402").error(f"Base tx verify failed: {e}")
|
|
return False
|
|
|
|
# Solana verification via public RPC
|
|
if len(tx_hash) == 88: # Solana base58 signature length
|
|
try:
|
|
import httpx
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.post("https://api.mainnet-beta.solana.com", json={
|
|
"jsonrpc": "2.0", "id": 1, "method": "getTransaction",
|
|
"params": [tx_hash, {"encoding": "json", "maxSupportedTransactionVersion": 0}],
|
|
})
|
|
data = r.json()
|
|
return not ("result" not in data or data["result"] is None)
|
|
except Exception as e:
|
|
logging.getLogger("wp.x402").error(f"Solana tx verify failed: {e}")
|
|
return False
|
|
|
|
return False
|
|
|
|
|
|
async def record_receipt(catalog, tx_hash: str, tool: str, agent_id: str, amount_usd: float) -> bool:
|
|
"""Log the payment receipt to x402_receipts table."""
|
|
if not catalog._health.postgres:
|
|
return False
|
|
try:
|
|
async with catalog._pg_pool.acquire() as conn:
|
|
await conn.execute(
|
|
"""INSERT INTO x402_receipts (tx_hash, agent_id, tool, amount_usd, chain, paid_at, tier)
|
|
VALUES ($1, $2, $3, $4, $5, NOW(), $6)
|
|
ON CONFLICT (tx_hash) DO NOTHING""",
|
|
tx_hash, agent_id, tool, amount_usd,
|
|
os.getenv("X402_PAYMENT_CHAIN", "base"),
|
|
os.getenv("X402_DEFAULT_TIER", "pro"),
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
log.warning(f"receipt_record_fail: {e}")
|
|
return False
|
|
|
|
|
|
# ── Main middleware ────────────────────────────────────────────────
|
|
async def require_payment(
|
|
tool: str,
|
|
request: Request | None = None,
|
|
catalog=None,
|
|
) -> dict:
|
|
"""Verify x402 payment. Returns payment context dict.
|
|
|
|
Raises HTTPException 402 if payment is required but not provided.
|
|
Raises HTTPException 429 if rate limit is exceeded.
|
|
"""
|
|
agent_id = _agent_id_from_request(request)
|
|
if not catalog or not catalog._health.redis:
|
|
# No rate limiting available - log and pass
|
|
log.debug(f"x402_no_redis: {tool} by {agent_id}")
|
|
else:
|
|
await _check_rate_limit(catalog._redis, agent_id)
|
|
|
|
# Free tool - no payment required
|
|
if get_tool_price_usd(tool) == 0:
|
|
return {"agent_id": agent_id, "tool": tool, "tier": "free", "paid_via_x402": None}
|
|
|
|
# Paid tool - check X-Payment header
|
|
if request is None:
|
|
invoice = create_invoice(tool, agent_id)
|
|
raise HTTPException(
|
|
status_code=402,
|
|
detail={
|
|
"error": "payment_required",
|
|
"invoice": invoice,
|
|
"payment_url": f"{os.getenv('X402_PAYMENT_URL', 'https://pay.rugmunch.io')}/{invoice['id']}",
|
|
"instructions": "Pay on-chain, then retry with X-Payment header (base64 of {tx_hash, signature})",
|
|
},
|
|
)
|
|
|
|
x_payment = request.headers.get("X-Payment")
|
|
if not x_payment:
|
|
invoice = create_invoice(tool, agent_id)
|
|
raise HTTPException(
|
|
status_code=402,
|
|
detail={
|
|
"error": "payment_required",
|
|
"invoice": invoice,
|
|
"payment_url": f"{os.getenv('X402_PAYMENT_URL', 'https://pay.rugmunch.io')}/{invoice['id']}",
|
|
"instructions": "Pay on-chain, then retry with X-Payment header (base64 of {tx_hash, signature})",
|
|
},
|
|
)
|
|
|
|
decoded = verify_payment_header(x_payment, get_tool_price_usd(tool))
|
|
if not decoded:
|
|
raise HTTPException(status_code=402, detail="invalid X-Payment format")
|
|
tx_hash, _sig = decoded
|
|
if not await verify_payment_on_chain(tx_hash, get_tool_price_usd(tool)):
|
|
raise HTTPException(status_code=402, detail="invalid payment proof")
|
|
|
|
# T02: Burn the nonce atomically. Same tx_hash cannot be used twice.
|
|
if not await _burn_nonce(catalog, tx_hash):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail={
|
|
"error": "payment_already_used",
|
|
"tx_hash": tx_hash,
|
|
"message": "This payment receipt has already been used. Each tx_hash can only be used once.",
|
|
},
|
|
)
|
|
_X402_REVENUE_COUNTER.labels(tool=tool, tier="pro").inc(int(get_tool_price_usd(tool) * 100))
|
|
|
|
# Record the receipt
|
|
if catalog:
|
|
await record_receipt(
|
|
catalog, tx_hash, tool, agent_id, get_tool_price_usd(tool)
|
|
)
|
|
|
|
return {
|
|
"agent_id": agent_id,
|
|
"tool": tool,
|
|
"tier": "pro",
|
|
"paid_via_x402": tx_hash,
|
|
}
|