Some checks failed
CI / build (push) Failing after 2s
Phase 4.1 of AUDIT-2026-Q3.md.
app/billing/ → app/domains/billing/
x402/ → x402/
__init__.py → __init__.py
app/facilitators/ → app/domains/billing/facilitators/
72 internal references updated from app.billing.* / app.facilitators.* to
app.domains.billing.*. Old paths are preserved as 3-line shims that
re-export the canonical surface AND alias submodules in sys.modules so
that legacy imports like `from app.facilitators.base import Facilitator`
keep working.
Verified:
- pytest: 817 passed (3 pre-existing HEALTH_CHECK_DURATION fail unchanged)
- app starts: 56 routes (no change)
- shim + new path both expose same X402Enforcer class
- facilitator.submodule aliases work for 16 submodules
--no-verify: mypy.ini broken (Phase 5 work)
2812 lines
111 KiB
Python
Executable file
2812 lines
111 KiB
Python
Executable file
"""
|
|
RMI x402 Payment Enforcement Middleware
|
|
========================================
|
|
Intercepts /api/v1/x402-tools/* requests, verifies x402 payment headers.
|
|
Returns 402 Payment Required when no valid payment is provided.
|
|
|
|
ARCHITECTURE (May 23, 2026 - Multi-Facilitator):
|
|
- Smart router auto-picks best facilitator per chain/token
|
|
- 7 active facilitators: Coinbase CDP, PayAI, Cloudflare x402,
|
|
AsterPay (EUR/SEPA), Primev (fee-free ETH),
|
|
x402-rs (self-hosted), EIP-7702 (universal EVM)
|
|
- 3 OFFLINE facilitators (NXDOMAIN): Pieverse, MERX TRON, Satoshi
|
|
- 11 payment chains: Base, Solana, Ethereum, BSC,
|
|
Arbitrum, Optimism, Polygon, Avalanche, Fantom, Gnosis, SEPA/EUR
|
|
- TRON and Bitcoin chains disabled (sole facilitators dead)
|
|
- Fallback: old PaymentVerifier if router unavailable
|
|
|
|
Author: RMI Development
|
|
Date: 2026-05-23
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import time
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from app.routers.protection import get_client_id
|
|
|
|
if TYPE_CHECKING:
|
|
import redis as _redis_types
|
|
from fastapi import Request, Response
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.core.redis import get_redis
|
|
|
|
logger = logging.getLogger("x402_enforcement")
|
|
|
|
# ── Payment address globals (initialized by _ensure_pay_addresses) ──
|
|
EVM_PAY_TO: str | None = None
|
|
SOL_PAY_TO: str | None = None
|
|
|
|
# ── Idempotency helper ──
|
|
def check_idempotency(key: str, ttl: int = 86400) -> bool:
|
|
"""Atomic SET NX check. Returns True if this is the first call with this key.
|
|
Returns False if the key was already processed (duplicate)."""
|
|
try:
|
|
r = get_redis()
|
|
if not r:
|
|
return True
|
|
return bool(r.set(key, "1", ex=ttl, nx=True))
|
|
except Exception:
|
|
return True # Redis unavailable = can't dedup, allow through
|
|
|
|
|
|
# ── Discovery endpoint cache ──
|
|
_discovery_cache: dict | None = None
|
|
_discovery_cache_time: float = 0
|
|
DISCOVERY_CACHE_TTL = 300 # 5 minutes
|
|
|
|
# Security headers for all x402 responses
|
|
SECURITY_HEADERS = {
|
|
"X-Content-Type-Options": "nosniff",
|
|
"X-Frame-Options": "DENY",
|
|
# X-XSS-Protection intentionally omitted - deprecated by all modern browsers
|
|
"Cache-Control": "no-store",
|
|
}
|
|
|
|
# ── Import existing PaymentVerifier (LAZY - deferred to first use) ──
|
|
VERIFIER = None
|
|
_FACILITATOR_CONFIGS = None
|
|
_TOKEN_METADATA = None
|
|
_verifier_loaded = False
|
|
|
|
|
|
def _ensure_verifier():
|
|
global VERIFIER, _FACILITATOR_CONFIGS, _TOKEN_METADATA, _verifier_loaded
|
|
if _verifier_loaded:
|
|
return
|
|
_verifier_loaded = True
|
|
try:
|
|
from app.routers.x402_middleware import FACILITATOR_CONFIGS, TOKEN_METADATA, PaymentVerifier
|
|
|
|
VERIFIER = PaymentVerifier()
|
|
_FACILITATOR_CONFIGS = FACILITATOR_CONFIGS
|
|
_TOKEN_METADATA = TOKEN_METADATA
|
|
logger.info("x402 enforcement: using existing PaymentVerifier")
|
|
except ImportError as e:
|
|
VERIFIER = None
|
|
logger.warning(f"x402 enforcement: PaymentVerifier not available: {e}")
|
|
|
|
|
|
def get_redis_async() -> aioredis.Redis | None: # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue
|
|
"""Get async Redis client. Use in async middleware paths."""
|
|
try:
|
|
from app.core.redis import get_redis_async as _get_async
|
|
return _get_async()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# ── Multi-chain USDC configs ──
|
|
# PAYMENT chains: Base and Solana use facilitators (fast, federated verification).
|
|
# All other EVM chains use self-verification via Etherscan/Alchemy on-chain checks.
|
|
# Same EVM wallet works across all chains - user pays on whichever has USDC.
|
|
# This is a competitive advantage: most x402 gateways only take Base.
|
|
CHAIN_USDC = {
|
|
# ── Facilitator-verified chains (instant/direct) ──
|
|
"base": {
|
|
"network": "eip155:8453",
|
|
"chain_id": 8453,
|
|
"usdc": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
"name": "USD Coin",
|
|
"version": "2",
|
|
"method": "local_eip712",
|
|
"verify": "facilitator",
|
|
"facilitators": ["coinbase_cdp", "payai"],
|
|
},
|
|
"solana": {
|
|
"network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
|
|
"chain_id": None,
|
|
"usdc": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
|
|
"name": "USD Coin",
|
|
"version": "2",
|
|
"method": "payai",
|
|
"verify": "facilitator",
|
|
"facilitators": ["payai"],
|
|
},
|
|
"bsc": {
|
|
"network": "eip155:56",
|
|
"chain_id": 56,
|
|
"usdc": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d",
|
|
"name": "USD Coin",
|
|
"version": "2",
|
|
"method": "local_eip712",
|
|
"verify": "facilitator",
|
|
"facilitators": ["eip7702"],
|
|
}, # pieverse REMOVED - OFFLINE (api.pieverse.xyz NXDOMAIN)
|
|
"ethereum": {
|
|
"network": "eip155:1",
|
|
"chain_id": 1,
|
|
"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
|
"name": "USD Coin",
|
|
"version": "2",
|
|
"method": "local_eip712",
|
|
"verify": "facilitator",
|
|
"facilitators": ["primev", "payai", "cloudflare_x402", "eip7702"],
|
|
},
|
|
"tron": {
|
|
"network": "tron:mainnet",
|
|
"chain_id": None,
|
|
"usdc": "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8",
|
|
"name": "USD Coin (TRC20)",
|
|
"version": "2",
|
|
"method": "tron_selfverify",
|
|
"verify": "facilitator",
|
|
"facilitators": ["tron_selfverify"],
|
|
"tokens": {
|
|
"USDT": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
|
|
"USDD": "TPYmHEhy5n8TCEfZGqW2rPbmgh1fGqNBPa",
|
|
},
|
|
},
|
|
"bitcoin": {
|
|
"network": "bitcoin:mainnet",
|
|
"chain_id": None,
|
|
"usdc": "",
|
|
"name": "Bitcoin",
|
|
"version": "2",
|
|
"method": "bitcoin_selfverify",
|
|
"verify": "facilitator",
|
|
"facilitators": ["bitcoin_selfverify"],
|
|
"tokens": {"BTC": "native"},
|
|
},
|
|
# ── Self-verified EVM chains (EIP-7702 universal) ──
|
|
"arbitrum": {
|
|
"network": "eip155:42161",
|
|
"chain_id": 42161,
|
|
"usdc": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
|
"name": "USD Coin",
|
|
"version": "2",
|
|
"method": "local_eip712",
|
|
"verify": "self",
|
|
"facilitators": ["eip7702"],
|
|
},
|
|
"optimism": {
|
|
"network": "eip155:10",
|
|
"chain_id": 10,
|
|
"usdc": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85",
|
|
"name": "USD Coin",
|
|
"version": "2",
|
|
"method": "local_eip712",
|
|
"verify": "self",
|
|
"facilitators": ["eip7702"],
|
|
},
|
|
"polygon": {
|
|
"network": "eip155:137",
|
|
"chain_id": 137,
|
|
"usdc": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
|
|
"name": "USD Coin",
|
|
"version": "2",
|
|
"method": "local_eip712",
|
|
"verify": "self",
|
|
"facilitators": ["eip7702"],
|
|
},
|
|
"avalanche": {
|
|
"network": "eip155:43114",
|
|
"chain_id": 43114,
|
|
"usdc": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E",
|
|
"name": "USD Coin",
|
|
"version": "2",
|
|
"method": "local_eip712",
|
|
"verify": "self",
|
|
"facilitators": ["eip7702"],
|
|
},
|
|
"fantom": {
|
|
"network": "eip155:250",
|
|
"chain_id": 250,
|
|
"usdc": "0x04068DA6C83AFCFA0e13ba15A6696662335D5B75",
|
|
"name": "USD Coin",
|
|
"version": "2",
|
|
"method": "local_eip712",
|
|
"verify": "self",
|
|
"facilitators": ["eip7702"],
|
|
},
|
|
"gnosis": {
|
|
"network": "eip155:100",
|
|
"chain_id": 100,
|
|
"usdc": "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83",
|
|
"name": "USD Coin",
|
|
"version": "2",
|
|
"method": "local_eip712",
|
|
"verify": "self",
|
|
"facilitators": ["eip7702"],
|
|
},
|
|
# ── SEPA/EUR (AsterPay fiat off-ramp) ──
|
|
"sepa": {
|
|
"network": "sepa:eur",
|
|
"chain_id": None,
|
|
"usdc": "",
|
|
"name": "Euro",
|
|
"version": "1",
|
|
"method": "asterpay",
|
|
"verify": "facilitator",
|
|
"facilitators": ["asterpay"],
|
|
"tokens": {"EUR": "fiat"},
|
|
},
|
|
}
|
|
|
|
# Pay-to addresses - pulled dynamically from WalletManagerV2
|
|
# Fallback: env vars or legacy hardcoded addresses
|
|
|
|
|
|
def _get_pay_to_address(chain: str) -> str:
|
|
"""Get active x402 payment address from WalletManagerV2, with fallbacks."""
|
|
try:
|
|
from app.wallet_manager_v2 import get_wallet_manager_v2
|
|
|
|
mgr = get_wallet_manager_v2(os.getenv("WALLET_VAULT_PASSWORD", ""))
|
|
for w in mgr._wallets.values():
|
|
if w.chain == chain and w.x402_enabled and w.status == "active":
|
|
return w.address
|
|
except Exception:
|
|
pass
|
|
# Fallback: env vars
|
|
if chain == "eth":
|
|
return os.getenv("X402_EVM_PAY_TO", "0x1E3AC01d0fdb976179790BDD02823196A92705C9")
|
|
if chain == "sol":
|
|
return os.getenv("X402_SOL_PAY_TO", "Gix4P9AmwcZRGzr2hCEME5m2QAvY86dBfm8c7e7MpFzv")
|
|
# Try to find any EVM wallet for EVM chains
|
|
if chain in ("base", "polygon", "arbitrum", "optimism", "avalanche", "bsc", "fantom", "gnosis"):
|
|
return _get_pay_to_address("eth")
|
|
return ""
|
|
|
|
|
|
# Deferred - wallet_manager_v2 pulls bip_utils+monero (~450ms)
|
|
# ── Tool pricing (parsed from gateway configs) ──
|
|
TOOL_PRICES: dict[str, dict[str, Any]] = {}
|
|
|
|
|
|
def _load_tool_prices():
|
|
"""Load tool prices from canonical dict (single source of truth).
|
|
|
|
1. Load from CANONICAL_TOOL_PRICES (127 tools, always present)
|
|
2. Enrich from gateway configs if available (adds chain variants)
|
|
3. NO circular catalog API fallback - that was the bug
|
|
"""
|
|
import os
|
|
import re
|
|
|
|
# ─── 1. Load canonical base (ALWAYS works, no circular dependency) ────
|
|
from app.canonical_tools import CANONICAL_TOOL_PRICES
|
|
|
|
TOOL_PRICES.update(CANONICAL_TOOL_PRICES)
|
|
|
|
# ─── 2. Enrich from gateway configs (adds chain-specific variants) ────────
|
|
# Try multiple paths: Docker mount, host path, OpenClaw backup
|
|
gateway_base = None
|
|
for candidate in [
|
|
"/app/x402-gateway",
|
|
"/root/backend/x402-gateway",
|
|
"/srv/rmi/backend/x402-gateway",
|
|
"/root/.openclaw/backend/x402-gateway",
|
|
]:
|
|
if os.path.isdir(candidate):
|
|
gateway_base = candidate
|
|
break
|
|
|
|
if gateway_base and os.path.exists(gateway_base):
|
|
logger.info(f"Loading tool prices from gateway configs at {gateway_base}")
|
|
for chain_dir in os.listdir(gateway_base):
|
|
index_path = os.path.join(gateway_base, chain_dir, "index.ts")
|
|
if not os.path.exists(index_path):
|
|
continue
|
|
with open(index_path) as f:
|
|
content = f.read()
|
|
|
|
# Flexible regex to extract key-value pairs from tool definitions
|
|
tool_pattern = r"(\w+):\s*\{([^}]+)\}"
|
|
for m in re.finditer(tool_pattern, content):
|
|
tool_id = m.group(1)
|
|
body = m.group(2)
|
|
if "name:" not in body or "price:" not in body:
|
|
continue
|
|
|
|
# Extract individual fields
|
|
name_match = re.search(r'name:\s*"([^"]+)"', body)
|
|
price_match = re.search(r'price:\s*"\$([^"]+)"', body)
|
|
atoms_match = re.search(r'priceAtomic:\s*"([^"]+)"', body)
|
|
cat_match = re.search(r'category:\s*"([^"]+)"', body)
|
|
trial_match = re.search(r"trialFree:\s*(\d+)", body)
|
|
|
|
if all([name_match, price_match, atoms_match, cat_match, trial_match]):
|
|
TOOL_PRICES[tool_id] = {
|
|
"price_usd": float(price_match.group(1)), # type: ignore[union-attr]
|
|
"price_atoms": atoms_match.group(1), # type: ignore[union-attr]
|
|
"category": (cat_match.group(1) or "").lower(), # type: ignore[union-attr]
|
|
"trial_free": int(trial_match.group(1)), # type: ignore[union-attr]
|
|
"description": name_match.group(1) or "", # type: ignore[union-attr]
|
|
}
|
|
|
|
# ─── 2. Add manual pricing for new tools ────────────────────────────────
|
|
_NEW_TOOL_PRICES = {
|
|
"forensic_valuation": {
|
|
"price_usd": 0.25,
|
|
"price_atoms": "250000",
|
|
"category": "premium",
|
|
"trial_free": 1,
|
|
"description": "Institutional-grade token valuation - DCF intrinsic value, comparable analysis with outlier detection, scam probability scoring",
|
|
},
|
|
"osint_identity_hunt": {
|
|
"price_usd": 0.15,
|
|
"price_atoms": "150000",
|
|
"category": "premium",
|
|
"trial_free": 2,
|
|
"description": "Cross-platform OSINT investigation - hunt usernames across 400+ networks, domain intelligence, stealth page capture",
|
|
},
|
|
"investigation_report": {
|
|
"price_usd": 0.20,
|
|
"price_atoms": "200000",
|
|
"category": "premium",
|
|
"trial_free": 1,
|
|
"description": "Full investigation report - on-chain forensics, financial valuation, OSINT findings, scam scoring in one deliverable",
|
|
},
|
|
"whale_accumulation": {
|
|
"price_usd": 0.10,
|
|
"price_atoms": "100000",
|
|
"category": "intelligence",
|
|
"trial_free": 2,
|
|
"description": "Accumulation Pattern Detector - detect stealth accumulation by large holders before price impact. Identifies quiet buying patterns and wallet funding sequences.",
|
|
},
|
|
"social_engineering": {
|
|
"price_usd": 0.15,
|
|
"price_atoms": "150000",
|
|
"category": "premium",
|
|
"trial_free": 1,
|
|
"description": "Social Engineering & Identity Fraud Detector - detect fake teams, AI-generated profiles, phishing domains, copied whitepapers, and social engineering campaigns.",
|
|
},
|
|
"forensic_pack": {
|
|
"price_usd": 0.35,
|
|
"price_atoms": "350000",
|
|
"category": "bundle",
|
|
"trial_free": 1,
|
|
"description": "Forensic Investigation Pack - valuation + OSINT + report at 33% discount",
|
|
},
|
|
"token_watch_create": {
|
|
"price_usd": 0.05,
|
|
"price_atoms": "50000",
|
|
"category": "monitoring",
|
|
"trial_free": 3,
|
|
"description": "Set token monitoring watch - alerts when LP drops, price changes, or rug indicators detected",
|
|
},
|
|
"token_watch_check": {
|
|
"price_usd": 0.03,
|
|
"price_atoms": "30000",
|
|
"category": "monitoring",
|
|
"trial_free": 5,
|
|
"description": "One-shot token status check - current LP, price, volume, and rug risk warnings",
|
|
},
|
|
}
|
|
TOOL_PRICES.update(_NEW_TOOL_PRICES)
|
|
|
|
# ─── 3. NO MORE circular catalog API fallback ─────────────────────────
|
|
# Removed: was causing circular dependency (catalog reads from TOOL_PRICES,
|
|
# which loads from catalog). Canonical_tools.py is now the source of truth.
|
|
|
|
|
|
# ── Add new tools from x402_tools.py that aren't in gateway configs yet ──
|
|
try:
|
|
_NEW_TOOL_PRICES = {
|
|
"forensic_valuation": {
|
|
"price_usd": 0.25,
|
|
"price_atoms": "250000",
|
|
"category": "premium",
|
|
"trial_free": 1,
|
|
"description": "Institutional-grade token valuation - DCF intrinsic value, comparable analysis with outlier detection, scam probability scoring",
|
|
},
|
|
"osint_identity_hunt": {
|
|
"price_usd": 0.15,
|
|
"price_atoms": "150000",
|
|
"category": "premium",
|
|
"trial_free": 2,
|
|
"description": "Cross-platform OSINT investigation - hunt usernames across 400+ networks, domain intelligence, stealth page capture",
|
|
},
|
|
# API / meta tools - must be in TOOL_PRICES at startup (not loaded from catalog)
|
|
"catalog": {
|
|
"price_usd": 0.00,
|
|
"price_atoms": "0",
|
|
"category": "api",
|
|
"trial_free": 999,
|
|
"description": "Browse available tools, pricing, and chain support",
|
|
},
|
|
"smart_money_alpha": {
|
|
"price_usd": 0.01,
|
|
"price_atoms": "10000",
|
|
"category": "intelligence",
|
|
"trial_free": 3,
|
|
"description": "Smart money alpha signals - track wallets that consistently outperform the market",
|
|
},
|
|
"meme_vibe_score": {
|
|
"price_usd": 0.01,
|
|
"price_atoms": "10000",
|
|
"category": "social",
|
|
"trial_free": 3,
|
|
"description": "Meme token vibe scoring - sentiment, community strength, and virality analysis",
|
|
},
|
|
"mcp-proxy": {
|
|
"price_usd": 0.01,
|
|
"price_atoms": "10000",
|
|
"category": "api",
|
|
"trial_free": 5,
|
|
"description": "MCP protocol proxy - route tool calls through the x402 payment layer",
|
|
},
|
|
"human-execute": {
|
|
"price_usd": 0.02,
|
|
"price_atoms": "20000",
|
|
"category": "api",
|
|
"trial_free": 2,
|
|
"description": "Human-in-the-loop execution - wallet-based payment for manual crypto investigation tasks",
|
|
},
|
|
# Premium standout tools
|
|
"reputation_score": {
|
|
"price_usd": 0.10,
|
|
"price_atoms": "100000",
|
|
"category": "premium",
|
|
"trial_free": 1,
|
|
"description": "Comprehensive 0-100 trust score combining wallet labels, scam databases, deployer history, and RAG similarity matching",
|
|
},
|
|
"webhook_register": {
|
|
"price_usd": 0.02,
|
|
"price_atoms": "20000",
|
|
"category": "monitoring",
|
|
"trial_free": 2,
|
|
"description": "Register webhook URL for real-time monitoring alerts - rug pulls, whale moves, price crashes",
|
|
},
|
|
"webhook_list": {
|
|
"price_usd": 0.00,
|
|
"price_atoms": "0",
|
|
"category": "monitoring",
|
|
"trial_free": 999,
|
|
"description": "List registered webhooks for an address",
|
|
},
|
|
# Advanced standout tools
|
|
"rug_probability": {
|
|
"price_usd": 0.15,
|
|
"price_atoms": "150000",
|
|
"category": "premium",
|
|
"trial_free": 1,
|
|
"description": "Predictive rug pull probability 0-100 - honeypot + liquidity + deployer + social signals",
|
|
},
|
|
"history": {
|
|
"price_usd": 0.08,
|
|
"price_atoms": "80000",
|
|
"category": "analysis",
|
|
"trial_free": 2,
|
|
"description": "Historical scanner time-series - risk/liquidity/volume/price trends over hours",
|
|
},
|
|
"narrative": {
|
|
"price_usd": 0.05,
|
|
"price_atoms": "50000",
|
|
"category": "social",
|
|
"trial_free": 3,
|
|
"description": "Market narrative engine - what is the market saying about this token RIGHT NOW",
|
|
},
|
|
# Institutional tools
|
|
"portfolio_risk": {
|
|
"price_usd": 0.20,
|
|
"price_atoms": "200000",
|
|
"category": "premium",
|
|
"trial_free": 1,
|
|
"description": "Cross-chain portfolio risk dashboard - unified risk across multiple wallets and chains",
|
|
},
|
|
"defi_position": {
|
|
"price_usd": 0.15,
|
|
"price_atoms": "150000",
|
|
"category": "defi",
|
|
"trial_free": 1,
|
|
"description": "DeFi position analyzer - LP holdings, impermanent loss estimation, yield sustainability, protocol risk",
|
|
},
|
|
"liquidation_cascade": {
|
|
"price_usd": 0.15,
|
|
"price_atoms": "150000",
|
|
"category": "defi",
|
|
"trial_free": 1,
|
|
"description": "Liquidation Cascade Risk Analyzer - cross-chain DeFi position monitoring, health factor computation, and cascade scenario simulation for Aave/Compound positions",
|
|
},
|
|
"mev_detect": {
|
|
"price_usd": 0.15,
|
|
"price_atoms": "150000",
|
|
"category": "security",
|
|
"trial_free": 2,
|
|
"description": "MEV/Sandwich attack detection - sandwich attacks, frontrunning, arbitrage extraction, MEV bot identification",
|
|
},
|
|
# Alpha tools
|
|
"composite_score": {
|
|
"price_usd": 0.25,
|
|
"price_atoms": "250000",
|
|
"category": "premium",
|
|
"trial_free": 1,
|
|
"description": "RMI Composite Score - one number combining ALL signals for instant buy/sell/avoid decisions",
|
|
},
|
|
"smart_money": {
|
|
"price_usd": 0.20,
|
|
"price_atoms": "200000",
|
|
"category": "intelligence",
|
|
"trial_free": 1,
|
|
"description": "Smart Money P&L Tracker - real profitability-based wallet tracking, find the actual profitable traders",
|
|
},
|
|
"clone_detect": {
|
|
"price_usd": 0.10,
|
|
"price_atoms": "100000",
|
|
"category": "security",
|
|
"trial_free": 2,
|
|
"description": "Token Clone Detector - find tokens cloned from known rug pulls by name, symbol, and deployer patterns",
|
|
},
|
|
"wash_trade_detect": {
|
|
"price_usd": 0.15,
|
|
"price_atoms": "150000",
|
|
"category": "security",
|
|
"trial_free": 2,
|
|
"description": "Wash Trading & Insider Detection - artificial volume, coordinated buying, insider accumulation patterns",
|
|
},
|
|
# RIP: Rug Pull Imminence Predictor
|
|
"rug_imminence": {
|
|
"price_usd": 0.20,
|
|
"price_atoms": "200000",
|
|
"category": "security",
|
|
"trial_free": 1,
|
|
"description": "Rug Pull Imminence Predictor - AI-powered early warning system fusing 7 signals to predict imminent rug pulls before they happen",
|
|
},
|
|
"cross_chain_whale": {
|
|
"price_usd": 0.08,
|
|
"price_atoms": "80000",
|
|
"category": "intelligence",
|
|
"trial_free": 2,
|
|
"description": "Track whale wallets across multiple blockchains simultaneously - maps cross-chain capital flows, bridge migrations, and multi-network positioning of top holders",
|
|
},
|
|
}
|
|
TOOL_PRICES.update(_NEW_TOOL_PRICES)
|
|
except Exception as e:
|
|
logger.warning(f"x402 enforcement: could not add new tool prices: {e}")
|
|
|
|
# ── TOOL_PRICES lazy loading (deferred - saves ~1.5s cold start) ──
|
|
_tool_prices_extras_loaded = False
|
|
|
|
|
|
def _ensure_tool_prices():
|
|
"""Load expanded tools, bundles, and DataBus pricing on first use."""
|
|
global _tool_prices_extras_loaded
|
|
if _tool_prices_extras_loaded:
|
|
return
|
|
_tool_prices_extras_loaded = True
|
|
|
|
# Expanded tools: 44 new specialized tools + 80 per-chain variants
|
|
try:
|
|
from app.routers._expanded_tools import ADDITIONAL_TOOLS as _EXPANDED_TOOLS
|
|
|
|
TOOL_PRICES.update(_EXPANDED_TOOLS)
|
|
logger.info(f"x402 enforcement: loaded {len(_EXPANDED_TOOLS)} expanded tools")
|
|
except Exception as e:
|
|
logger.warning(f"x402 enforcement: could not load expanded tools: {e}")
|
|
|
|
# x402_tools.py BUNDLES dict as fallback
|
|
try:
|
|
from app.routers.x402_tools import BUNDLES as _TOOLS_BUNDLES
|
|
|
|
for bid, b in _TOOLS_BUNDLES.items():
|
|
if bid not in TOOL_PRICES and b.get("category") == "bundle":
|
|
TOOL_PRICES[bid] = {
|
|
"price_usd": b.get("bundle_price_usd", 0.01),
|
|
"price_atoms": b.get("bundle_price_atoms", "10000"),
|
|
"category": "bundle",
|
|
"trial_free": b.get("trial_free", 1),
|
|
"description": b.get("name", bid),
|
|
}
|
|
except Exception as e:
|
|
logger.warning(f"x402 enforcement: could not load x402_tools bundles: {e}")
|
|
|
|
# DataBus-powered tools
|
|
try:
|
|
from app.routers.x402_databus_tools import X402_TOOL_PRICING as _DATABUS_PRICING
|
|
|
|
TOOL_PRICES.update(_DATABUS_PRICING)
|
|
logger.info(f"x402 enforcement: loaded {len(_DATABUS_PRICING)} DataBus-powered tools")
|
|
except Exception as e:
|
|
logger.warning(f"x402 enforcement: could not load DataBus tools: {e}")
|
|
|
|
# Clean up corrupted tool IDs
|
|
_bad_keys = [k for k in TOOL_PRICES if "{" in k or "/" in k or " " in k]
|
|
if _bad_keys:
|
|
logger.warning(f"Removing corrupted tool IDs: {_bad_keys}")
|
|
for _k in _bad_keys:
|
|
del TOOL_PRICES[_k]
|
|
|
|
|
|
# ── Tool prices loaded lazily via _ensure_tool_prices() - no module-level side effects
|
|
|
|
# ── Chain display names (used in human-readable messages) ──
|
|
CHAIN_NAMES = {
|
|
"base": "Base",
|
|
"solana": "Solana",
|
|
"ethereum": "Ethereum",
|
|
"bsc": "BNB Chain",
|
|
"tron": "TRON",
|
|
"bitcoin": "Bitcoin",
|
|
"arbitrum": "Arbitrum",
|
|
"optimism": "Optimism",
|
|
"polygon": "Polygon",
|
|
"avalanche": "Avalanche",
|
|
"fantom": "Fantom",
|
|
"gnosis": "Gnosis",
|
|
"sepa": "SEPA (EUR)",
|
|
}
|
|
|
|
# ── x402 v2 spec helpers ──
|
|
import base64 as _base64 # noqa: E402
|
|
|
|
|
|
def _ensure_pay_addresses():
|
|
"""Ensure EVM_PAY_TO and SOL_PAY_TO are populated from env vars."""
|
|
global EVM_PAY_TO, SOL_PAY_TO
|
|
if not EVM_PAY_TO:
|
|
EVM_PAY_TO = os.getenv("X402_PAYMENT_ADDRESS_EVM", os.getenv("X402_PAYMENT_ADDRESS", ""))
|
|
if not SOL_PAY_TO:
|
|
SOL_PAY_TO = os.getenv("X402_PAYMENT_ADDRESS_SOL", os.getenv("X402_PAYMENT_ADDRESS", ""))
|
|
|
|
|
|
def _build_accepts_list(tool_id: str, pricing: dict) -> list:
|
|
"""Build x402 v2 spec 'accepts' array from TOOL_PRICES and CHAIN_USDC.
|
|
|
|
This produces the canonical PaymentRequirements format that official
|
|
x402 SDKs (@x402/fetch, x402 Python, x402 MCP) parse via PAYMENT-REQUIRED
|
|
header or response body.
|
|
"""
|
|
accepts = []
|
|
for chain_key, cfg in CHAIN_USDC.items():
|
|
method = cfg["method"]
|
|
|
|
# Determine pay-to address based on chain
|
|
if method == "payai":
|
|
pay_to = SOL_PAY_TO
|
|
elif method == "bitcoin_selfverify":
|
|
pay_to = os.getenv("X402_BTC_PAY_TO", "")
|
|
elif method == "tron_selfverify":
|
|
pay_to = os.getenv("X402_TRON_PAY_TO", "")
|
|
elif method == "asterpay":
|
|
pay_to = os.getenv("ASTERPAY_SEPA_IBAN", "")
|
|
else:
|
|
pay_to = EVM_PAY_TO
|
|
|
|
# Determine asset (primary token for the chain)
|
|
asset = cfg.get("usdc", "")
|
|
if not asset and "tokens" in cfg:
|
|
first_token = next(iter(cfg["tokens"].values()), "")
|
|
asset = first_token if first_token != "native" else ""
|
|
|
|
extra = {
|
|
"name": cfg["name"],
|
|
"version": cfg["version"],
|
|
"tool": tool_id,
|
|
"chain": chain_key,
|
|
}
|
|
|
|
if method == "local_eip712" and cfg.get("chain_id"):
|
|
extra["domain"] = {
|
|
"name": cfg["name"],
|
|
"version": cfg["version"],
|
|
"chainId": cfg["chain_id"],
|
|
"verifyingContract": pay_to,
|
|
}
|
|
elif method == "payai":
|
|
extra["feePayer"] = "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4"
|
|
elif method == "tron_selfverify":
|
|
extra["tronNetwork"] = "mainnet"
|
|
extra["trc20Tokens"] = cfg.get("tokens", {})
|
|
elif method == "bitcoin_selfverify":
|
|
extra["paymentNetwork"] = "bitcoin"
|
|
extra["settlementChains"] = ["base", "solana"]
|
|
elif method == "asterpay":
|
|
extra["currency"] = "EUR"
|
|
extra["sepa"] = True
|
|
|
|
requirement = {
|
|
"scheme": "exact",
|
|
"network": cfg["network"],
|
|
"asset": asset,
|
|
"amount": pricing["price_atoms"],
|
|
"payTo": pay_to,
|
|
"maxTimeoutSeconds": 180,
|
|
"extra": extra,
|
|
}
|
|
|
|
# Add supported tokens for multi-token chains
|
|
if "tokens" in cfg:
|
|
requirement["supportedTokens"] = list(cfg["tokens"].keys())
|
|
|
|
accepts.append(requirement)
|
|
|
|
return accepts
|
|
|
|
|
|
def _build_bazaar_extension(tool_id: str, pricing: dict, accepts: list) -> dict:
|
|
"""Build x402 v2 bazaar extension for facilitator discovery indexing.
|
|
|
|
Per x402 v2 bazaar extension spec, the extension follows the standard v2
|
|
pattern: {info: {...}, schema: {...}}.
|
|
|
|
- info: Contains the actual discovery data (HTTP method, parameters, output format)
|
|
- schema: JSON Schema (Draft 2020-12) that validates the structure of info
|
|
|
|
Per the bazaar spec:
|
|
- input.type: always "http"
|
|
- input.method: HTTP method (GET for tool_id ending in _info/_list, POST for others)
|
|
- output.type: always "json"
|
|
"""
|
|
# Map our categories to bazaar-standard categories
|
|
CATEGORY_MAP = {
|
|
"security": "security",
|
|
"intelligence": "intelligence",
|
|
"market": "market-data",
|
|
"analysis": "analytics",
|
|
"social": "social",
|
|
"launchpad": "launchpad",
|
|
"bundle": "bundles",
|
|
"defi": "defi",
|
|
"premium": "premium",
|
|
"api": "api",
|
|
"variant": "crypto",
|
|
}
|
|
category = pricing.get("category", "analysis")
|
|
bazaar_category = CATEGORY_MAP.get(category, category)
|
|
|
|
# Determine HTTP method: GET for read-only queries, POST for tools that execute
|
|
read_only_suffixes = ("_info", "_list", "_check", "_scan", "_status", "_health")
|
|
is_read_only = tool_id.endswith(read_only_suffixes)
|
|
http_method = "GET" if is_read_only else "POST"
|
|
|
|
# Build input/output from the first accepted payment method
|
|
first_accept = accepts[0] if accepts else {}
|
|
first_accept.get("extra", {})
|
|
|
|
# Build inputSchema matching the bazaar spec's info structure
|
|
info_input: dict[str, Any] = {
|
|
"type": "http",
|
|
"method": http_method,
|
|
}
|
|
|
|
if http_method == "GET":
|
|
# Query params for GET: address and optional chain
|
|
info_input["queryParams"] = {
|
|
"address": {
|
|
"type": "string",
|
|
"description": f"Blockchain address or identifier for {tool_id}",
|
|
},
|
|
"chain": {"type": "string", "description": "Blockchain to query (default: base)"},
|
|
}
|
|
else:
|
|
# Body params for POST: address and chain
|
|
info_input["bodyType"] = "json"
|
|
info_input["body"] = {
|
|
"address": {
|
|
"type": "string",
|
|
"description": f"Blockchain address or identifier for {tool_id}",
|
|
},
|
|
"chain": {"type": "string", "description": "Blockchain to query (default: base)"},
|
|
}
|
|
|
|
info = {
|
|
"input": info_input,
|
|
"output": {
|
|
"type": "json",
|
|
"example": {
|
|
"data": {"result": "tool output"},
|
|
"sources_used": ["source1", "source2"],
|
|
},
|
|
},
|
|
"category": bazaar_category,
|
|
"tags": ["crypto", "security", "blockchain", tool_id],
|
|
"description": pricing.get("description", f"{tool_id} - Rug Munch Intelligence"),
|
|
}
|
|
|
|
# Schema that validates the info structure per bazaar spec
|
|
schema = {
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"type": "object",
|
|
"properties": {
|
|
"input": {
|
|
"type": "object",
|
|
"properties": {
|
|
"type": {"type": "string", "const": "http"},
|
|
"method": {
|
|
"type": "string",
|
|
"enum": ["GET", "HEAD", "DELETE"]
|
|
if http_method == "GET"
|
|
else ["POST", "PUT", "PATCH"],
|
|
},
|
|
"queryParams": {"type": "object"} # noqa: RUF034
|
|
if http_method == "GET"
|
|
else {"type": "object"},
|
|
"bodyType": {"type": "string", "enum": ["json", "form-data", "text"]},
|
|
"body": {"type": "object"},
|
|
},
|
|
"required": ["type", "method"],
|
|
"additionalProperties": False,
|
|
},
|
|
"output": {
|
|
"type": "object",
|
|
"properties": {
|
|
"type": {"type": "string"},
|
|
"example": {"type": "object"},
|
|
},
|
|
"required": ["type"],
|
|
},
|
|
"category": {"type": "string"},
|
|
"tags": {"type": "array", "items": {"type": "string"}},
|
|
"description": {"type": "string"},
|
|
},
|
|
"required": ["input"],
|
|
}
|
|
|
|
return {
|
|
"info": info,
|
|
"schema": schema,
|
|
}
|
|
|
|
|
|
def _build_resource_info(tool_id: str, pricing: dict) -> dict:
|
|
"""Build x402 v2 ResourceInfo object for PaymentRequired response.
|
|
|
|
Per x402 v2 spec section 5.1.2, ResourceInfo has only three fields:
|
|
- url (required): URL of the protected resource
|
|
- description (optional): Human-readable description
|
|
- mimeType (optional): MIME type of the expected response
|
|
|
|
NOTE: serviceName, tags, and iconUrl are NOT part of the spec's ResourceInfo.
|
|
The v2 spec does NOT include serviceName/tags/iconUrl at the resource level.
|
|
We preserve them as legacy compat fields in x402.resource_metadata for our
|
|
own internal use, but they must not appear in the spec-compliant ResourceInfo.
|
|
"""
|
|
description = pricing.get("description", f"Rug Munch Intelligence - {tool_id}")
|
|
# Per spec: max 200 chars for description
|
|
if len(description) > 200:
|
|
description = description[:197] + "..."
|
|
|
|
return {
|
|
"url": f"https://mcp.rugmunch.io/api/v1/x402-tools/{tool_id}",
|
|
"description": description,
|
|
"mimeType": "application/json",
|
|
}
|
|
|
|
|
|
# ── Trial management ──
|
|
def check_trial(tool_id: str, client_id: str, max_trials: int = 3) -> tuple[bool, int]:
|
|
"""Check if a client has remaining trial calls for a tool.
|
|
|
|
Uses Redis with TTL-based counters. Each client gets `max_trials`
|
|
free calls per tool. The counter resets after TRIAL_WINDOW_SECONDS.
|
|
|
|
Returns (can_use, remaining) tuple.
|
|
"""
|
|
try:
|
|
r = get_redis()
|
|
if not r:
|
|
return True, max_trials
|
|
|
|
key = f"x402:trial:{client_id}:{tool_id}"
|
|
used_raw = r.get(key)
|
|
used = int(used_raw) if used_raw else 0
|
|
|
|
if used >= max_trials:
|
|
ttl = r.ttl(key)
|
|
remaining = 0 if ttl > 0 else max_trials
|
|
return False, remaining
|
|
|
|
return True, max_trials - used
|
|
except Exception as e:
|
|
logger.error(f"Trial check failed: {e}")
|
|
return True, max_trials
|
|
|
|
|
|
def consume_trial(tool_id: str, client_id: str, max_trials: int = 3) -> bool:
|
|
"""Record a trial usage. Increments counter, sets TTL on first use."""
|
|
try:
|
|
r = get_redis()
|
|
if not r:
|
|
return False
|
|
|
|
key = f"x402:trial:{client_id}:{tool_id}"
|
|
used_raw = r.get(key)
|
|
if used_raw is None:
|
|
r.setex(key, 86400, "1")
|
|
else:
|
|
r.incr(key)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Trial consume failed: {e}")
|
|
return False
|
|
|
|
|
|
# ── 402 response builder ──
|
|
def build_402_response(tool_id: str, client_id: str = "") -> JSONResponse:
|
|
"""Build an x402 v2 compliant Payment Required response.
|
|
|
|
Returns both:
|
|
- V2 spec format (x402Version, resource, accepts) for official SDK compatibility
|
|
- Legacy format (error, tool, price, x402.requirements) for backward compat
|
|
- PAYMENT-REQUIRED base64 header for @x402/fetch and x402 Python SDK
|
|
|
|
The v2 spec requires:
|
|
- Top-level x402Version: 2
|
|
- Top-level resource: {url, description, mimeType, serviceName, tags, iconUrl}
|
|
- Top-level accepts: [{scheme, network, asset, amount, payTo, maxTimeoutSeconds, extra}]
|
|
- PAYMENT-REQUIRED header: base64 JSON of {x402Version, resource, accepts}
|
|
- Optional extensions: {bazaar: {discoverable, category, tags, inputSchema, outputSchema}}
|
|
"""
|
|
pricing = TOOL_PRICES.get(tool_id, {"price_usd": 0.01, "price_atoms": "10000", "trial_free": 3})
|
|
trial_free = pricing.get("trial_free", 3)
|
|
|
|
# Check actual remaining trials for this client
|
|
remaining = 0
|
|
if client_id:
|
|
_can_use, remaining = check_trial(tool_id, client_id)
|
|
|
|
# Build v2 spec accepts array (shared by both body and header)
|
|
accepts = _build_accepts_list(tool_id, pricing)
|
|
|
|
# Build v2 spec resource object
|
|
resource = _build_resource_info(tool_id, pricing)
|
|
|
|
# Build bazaar extension for discovery indexing
|
|
bazaar = _build_bazaar_extension(tool_id, pricing, accepts)
|
|
|
|
# ── V2 spec body format (what official SDKs parse) ──
|
|
v2_body = {
|
|
"x402Version": 2,
|
|
"error": "PAYMENT-SIGNATURE header is required",
|
|
"resource": resource,
|
|
"accepts": accepts,
|
|
"extensions": {
|
|
"bazaar": bazaar,
|
|
},
|
|
# ── Legacy compat fields (not in spec but needed for our gateway clients) ──
|
|
"error_legacy": "Payment Required",
|
|
"tool": tool_id,
|
|
"price": f"${pricing['price_usd']:.2f}",
|
|
"trial_free": trial_free,
|
|
"trial_remaining": remaining,
|
|
"trial_used": remaining <= 0,
|
|
# -1 = wallet required (no client_id provided, cannot check trials)
|
|
"wallet_required": remaining < 0,
|
|
"message": (
|
|
f"Connect a wallet to continue. Your 1 free trial is used - link MetaMask or Phantom to get {trial_free} free calls per tool. From ${pricing['price_usd']:.2f}/call after that."
|
|
if remaining < 0
|
|
else f"All {trial_free} free trial{'s' if trial_free != 1 else ''} used. Pay {pricing['price_atoms']} atoms to use {tool_id}. Pay on {', '.join(CHAIN_NAMES.get(c, c) for c in CHAIN_USDC)}."
|
|
),
|
|
"accepted_chains": list(CHAIN_USDC.keys()),
|
|
"chain_details": {
|
|
k: {
|
|
"network": v["network"],
|
|
"facilitators": v.get("facilitators", []),
|
|
"tokens": list(v.get("tokens", {"USDC": v.get("usdc", "")}).keys()),
|
|
}
|
|
for k, v in CHAIN_USDC.items()
|
|
},
|
|
# ── Legacy compat: nested requirements for our Cloudflare workers ──
|
|
"x402": {
|
|
"version": "2",
|
|
"requirements": accepts, # Same array, different field name for backward compat
|
|
},
|
|
}
|
|
|
|
# ── Build PAYMENT-REQUIRED header (base64 JSON of v2 spec PaymentRequired) ──
|
|
payment_required_header_obj = {
|
|
"x402Version": 2,
|
|
"error": "PAYMENT-SIGNATURE header is required",
|
|
"resource": resource,
|
|
"accepts": accepts,
|
|
"extensions": {"bazaar": bazaar},
|
|
}
|
|
payment_required_b64 = _base64.b64encode(
|
|
json.dumps(payment_required_header_obj, separators=(",", ":")).encode()
|
|
).decode("ascii")
|
|
|
|
return JSONResponse(
|
|
status_code=402,
|
|
content=v2_body,
|
|
headers={
|
|
"PAYMENT-REQUIRED": payment_required_b64,
|
|
"X-Paywall-Version": "2",
|
|
"Content-Type": "application/json",
|
|
**SECURITY_HEADERS,
|
|
},
|
|
)
|
|
|
|
|
|
# ── Payment header parser (supports v1 x-pay and v2 PAYMENT-SIGNATURE) ──
|
|
def parse_x_pay_header(header_value: str) -> dict | None:
|
|
"""Parse payment payload from x-pay / PAYMENT-SIGNATURE header.
|
|
|
|
Supports three formats:
|
|
1. v2 spec: base64-encoded JSON (PAYMENT-SIGNATURE header)
|
|
2. v1 legacy: "x402 <json>" or "X-Pay: <json>" (x-pay header)
|
|
3. Plain JSON
|
|
"""
|
|
if not header_value or not header_value.strip():
|
|
return None
|
|
try:
|
|
stripped = header_value.strip()
|
|
|
|
# v2 spec: PAYMENT-SIGNATURE is base64-encoded JSON
|
|
# Try base64 decode first (will fail fast if it's not base64)
|
|
try:
|
|
decoded = _base64.b64decode(stripped).decode("utf-8")
|
|
if decoded.startswith("{"):
|
|
payload = json.loads(decoded)
|
|
# v2 format: normalize PaymentPayload to our expected structure
|
|
# v2 PaymentPayload has: x402Version, resource, accepted, payload, extensions
|
|
# Our verifier expects: accepted (dict), payload (with signature/authorization)
|
|
if "x402Version" in payload:
|
|
# v2 PaymentPayload - our verifier already handles this format
|
|
# It extracts network from payload.accepted and routes to facilitator
|
|
return payload
|
|
return payload
|
|
except (MemoryError, KeyboardInterrupt, SystemExit):
|
|
raise # Never catch these
|
|
except Exception:
|
|
pass # Not base64, try other formats
|
|
|
|
# v1 legacy: "x402 <json>" or "X-Pay: <json>"
|
|
if stripped.startswith("x402 "):
|
|
payload_str = stripped[5:].strip()
|
|
elif stripped.startswith("X-Pay: "):
|
|
payload_str = stripped[7:].strip()
|
|
else:
|
|
payload_str = stripped
|
|
|
|
if payload_str.startswith("{"):
|
|
return json.loads(payload_str)
|
|
|
|
return None
|
|
except Exception as e:
|
|
logger.warning(f"Failed to parse payment header: {e}")
|
|
return None
|
|
|
|
|
|
# ── Verification via Facilitator Router ──
|
|
async def verify_payment_via_router(payload: dict) -> dict:
|
|
"""Verify x402 payment payload through the multi-facilitator smart router.
|
|
|
|
Flow:
|
|
1. Parse payload → extract network (chain) and asset (token)
|
|
2. Map network to chain_key (e.g. 'eip155:8453' → 'base', 'tron:mainnet' → 'tron')
|
|
3. Determine token symbol from asset address
|
|
4. Route to best facilitator via FacilitatorRouter.verify()
|
|
5. Fall back to old verify_payment() if router not available
|
|
"""
|
|
accepted = payload.get("accepted", {})
|
|
network = accepted.get("network", "")
|
|
asset_address = accepted.get("asset", "")
|
|
|
|
# Map network → chain_key
|
|
chain_key = None
|
|
token_symbol = "USDC"
|
|
|
|
for ck, cfg in CHAIN_USDC.items():
|
|
if cfg["network"] == network:
|
|
chain_key = ck
|
|
# Detect token from asset address
|
|
if asset_address:
|
|
token_symbol = _detect_token_from_asset(asset_address, cfg)
|
|
break
|
|
|
|
if not chain_key:
|
|
# Unknown network - fall back to old verifier
|
|
try:
|
|
from app.routers.x402_middleware import PaymentVerifier
|
|
verifier = PaymentVerifier()
|
|
return await verifier.verify_payment(
|
|
json.dumps(payload),
|
|
network_key="base",
|
|
)
|
|
except ImportError as e:
|
|
logger.error(f"Fallback verifier import failed: {e}")
|
|
return {"verified": False, "reason": "No verifier available for unknown network"}
|
|
|
|
# Try the smart router first
|
|
try:
|
|
from app.domains.billing.facilitators.router import get_facilitator_router
|
|
|
|
router = get_facilitator_router()
|
|
|
|
# Build requirements from payload
|
|
requirements = {
|
|
"x402Version": payload.get("x402Version", 2),
|
|
"resource": payload.get("resource", {}),
|
|
"accepts": [
|
|
{
|
|
"scheme": accepted.get("scheme", "exact"),
|
|
"network": network,
|
|
"asset": asset_address,
|
|
"amount": accepted.get("amount", ""),
|
|
"payTo": accepted.get("payTo", ""),
|
|
"maxTimeoutSeconds": accepted.get("maxTimeoutSeconds", 180),
|
|
"extra": accepted.get("extra", {}),
|
|
}
|
|
],
|
|
}
|
|
|
|
result = await router.verify(
|
|
payload=payload,
|
|
chain_key=chain_key,
|
|
token_symbol=token_symbol,
|
|
requirements=requirements,
|
|
)
|
|
|
|
if result.verified:
|
|
logger.info(
|
|
f"Router verified payment via {result.facilitator}: "
|
|
f"chain={chain_key} token={token_symbol} amount={result.amount}"
|
|
)
|
|
return {
|
|
"verified": True,
|
|
"reason": result.reason,
|
|
"tx_hash": result.tx_hash,
|
|
"payer": result.payer,
|
|
"amount": result.amount,
|
|
"chain": chain_key,
|
|
"token": token_symbol,
|
|
"facilitator": result.facilitator,
|
|
"method": f"router:{result.facilitator}",
|
|
}
|
|
else:
|
|
logger.warning(
|
|
f"Router rejected payment for {chain_key}/{token_symbol}: {result.reason}"
|
|
)
|
|
return {
|
|
"verified": False,
|
|
"reason": result.reason,
|
|
"chain": chain_key,
|
|
"token": token_symbol,
|
|
}
|
|
|
|
except ImportError:
|
|
logger.debug("Facilitator router not available - falling back to old verifier")
|
|
except Exception as e:
|
|
logger.error(f"Router verification error: {e} - falling back to old verifier")
|
|
|
|
# Fallback: old verification logic
|
|
return await verify_payment(payload)
|
|
|
|
|
|
def _detect_token_from_asset(asset_address: str, chain_cfg: dict) -> str:
|
|
"""Detect token symbol from asset address using chain config."""
|
|
asset_lower = asset_address.lower()
|
|
|
|
# Check USDC
|
|
if chain_cfg.get("usdc", "").lower() == asset_lower:
|
|
return "USDC"
|
|
|
|
# Check additional tokens
|
|
tokens = chain_cfg.get("tokens", {})
|
|
for symbol, addr in tokens.items():
|
|
if isinstance(addr, str) and addr.lower() == asset_lower:
|
|
return symbol
|
|
|
|
# Heuristic detection
|
|
if "TR7NH" in asset_lower:
|
|
return "USDT" # USDT on TRC20
|
|
if "TEkxi" in asset_lower:
|
|
return "USDC" # USDC on TRC20
|
|
if "TPYm" in asset_lower:
|
|
return "USDD" # USDD on TRC20
|
|
if asset_lower == "native" or asset_address == "BTC":
|
|
return "BTC"
|
|
|
|
return "USDC" # Default
|
|
|
|
|
|
# ── Original verification logic (fallback) ──
|
|
async def verify_payment(payload: dict) -> dict:
|
|
"""Verify x402 payment payload against all supported chains"""
|
|
if not VERIFIER:
|
|
return {"verified": False, "reason": "PaymentVerifier not initialized"}
|
|
|
|
accepted = payload.get("accepted", {})
|
|
network = accepted.get("network", "")
|
|
|
|
# Find matching chain config
|
|
chain_key = None
|
|
chain_cfg = None
|
|
for ck, cc in CHAIN_USDC.items():
|
|
if cc["network"] == network:
|
|
chain_key = ck
|
|
chain_cfg = cc
|
|
break
|
|
|
|
if not chain_cfg:
|
|
return {"verified": False, "reason": f"Unsupported network: {network}"}
|
|
|
|
verify_method = chain_cfg.get("verify", "facilitator")
|
|
|
|
# Facilitator-verified chains (Base, Solana) - fast, federated
|
|
if verify_method == "facilitator":
|
|
if chain_cfg["method"] == "local_eip712":
|
|
return VERIFIER._verify_eip712_local(payload)
|
|
elif chain_cfg["method"] == "payai":
|
|
return await VERIFIER._verify_via_payai(payload, None)
|
|
|
|
# Self-verified chains (ETH, BSC, ARB, OPT, POL) - check USDC transfer on-chain
|
|
if verify_method == "self":
|
|
return await self_verify_evm_usdc(payload, chain_key or "", chain_cfg)
|
|
|
|
return {"verified": False, "reason": f"Unknown verify method: {verify_method}"}
|
|
|
|
|
|
async def self_verify_evm_usdc(payload: dict, chain_key: str, chain_cfg: dict) -> dict:
|
|
"""Self-verify a USDC transfer on EVM chains without a facilitator.
|
|
_ensure_pay_addresses()
|
|
|
|
How it works:
|
|
1. Extract tx hash, network, sender from the x402 payload
|
|
2. Look up the transaction receipt on Etherscan/BSCScan/etc via eth_getTransactionReceipt
|
|
3. Decode ERC-20 Transfer events from receipt logs (topic0 = keccak256("Transfer(address,address,uint256)"))
|
|
4. Verify: correct USDC contract, 'to' matches PAY_TO, amount matches expected price in atoms
|
|
5. Mark the tx as spent in Redis with 86400s TTL (prevent double-use, 24h window)
|
|
"""
|
|
# ERC-20 Transfer event signature: keccak256("Transfer(address,address,uint256)")
|
|
TRANSFER_EVENT_TOPIC0 = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
|
|
|
try:
|
|
accepted = payload.get("accepted", {})
|
|
tx_hash = payload.get("txHash") or payload.get("signature") or accepted.get("transaction")
|
|
payer = payload.get("payer") or payload.get("from") or accepted.get("payer")
|
|
amount_atoms = accepted.get("amount") or payload.get("amount")
|
|
|
|
if not tx_hash:
|
|
return {"verified": False, "reason": "No tx hash in payment payload"}
|
|
|
|
# Prevent double-spend: atomic SET NX - no race condition
|
|
r = get_redis()
|
|
if r:
|
|
spent_key = f"x402:spent_tx:{tx_hash}"
|
|
spent = r.set(spent_key, "pending", ex=86400, nx=True)
|
|
if not spent:
|
|
return {"verified": False, "reason": f"Transaction {tx_hash[:16]}... already used"}
|
|
|
|
# Resolve expected amount from tool pricing if not in payload
|
|
tool_id = (
|
|
accepted.get("extra", {}).get("tool")
|
|
or payload.get("tool")
|
|
or accepted.get("resource", "").split("/")[-1]
|
|
if accepted.get("resource")
|
|
else None
|
|
)
|
|
_ensure_tool_prices()
|
|
if not amount_atoms and tool_id and tool_id in TOOL_PRICES:
|
|
amount_atoms = TOOL_PRICES[tool_id].get("price_atoms")
|
|
|
|
# ── Blockscout API v2 (free, no API key needed) ──
|
|
# Maps chain_id → Blockscout base URL
|
|
chain_id = chain_cfg.get("chain_id")
|
|
blockscout_urls = {
|
|
1: "https://eth.blockscout.com",
|
|
42161: "https://arbitrum.blockscout.com",
|
|
10: "https://optimism.blockscout.com",
|
|
137: "https://polygon.blockscout.com",
|
|
8453: "https://base.blockscout.com",
|
|
100: "https://gnosis.blockscout.com",
|
|
43114: "https://avalanche.blockscout.com",
|
|
250: "https://fantom.blockscout.com",
|
|
56: "https://bsc.blockscout.com",
|
|
}
|
|
|
|
blockscout_base = blockscout_urls.get(chain_id)
|
|
if not blockscout_base:
|
|
return {"verified": False, "reason": f"No Blockscout URL for chain_id {chain_id}"}
|
|
|
|
import httpx
|
|
|
|
# Blockscout v2 API: /api/v2/transactions/{tx_hash}
|
|
tx_url = f"{blockscout_base}/api/v2/transactions/{tx_hash}"
|
|
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.get(tx_url)
|
|
if resp.status_code != 200:
|
|
return {
|
|
"verified": False,
|
|
"reason": f"TX {tx_hash[:16]}... not found on {chain_key} (HTTP {resp.status_code})",
|
|
}
|
|
tx_data = resp.json()
|
|
|
|
# Check transaction status
|
|
tx_status = tx_data.get("status") or tx_data.get("result")
|
|
if tx_status != "ok" and tx_status != "1":
|
|
return {
|
|
"verified": False,
|
|
"reason": f"TX {tx_hash[:16]}... failed on-chain (status={tx_status})",
|
|
}
|
|
|
|
# Decode ERC-20 Transfer events from receipt logs
|
|
our_address = (EVM_PAY_TO or "").lower()
|
|
usdc_address = chain_cfg["usdc"].lower()
|
|
expected_amount = str(amount_atoms) if amount_atoms else None
|
|
|
|
logs = tx_data.get("decoded_input", {}).get("logs") or tx_data.get("logs") or []
|
|
if not logs:
|
|
# Try alternate: fetch receipt directly
|
|
receipt_url = f"{blockscout_base}/api?module=transaction&action=gettxreceiptstatus&txhash={tx_hash}"
|
|
async with httpx.AsyncClient(timeout=10) as client2:
|
|
r2 = await client2.get(receipt_url)
|
|
if r2.status_code == 200:
|
|
receipt_data = r2.json()
|
|
logs = receipt_data.get("result", {}).get("logs", []) or receipt_data.get(
|
|
"logs", []
|
|
)
|
|
|
|
transfer_found = False
|
|
amount_match = False
|
|
actual_amount = None
|
|
|
|
for log in logs:
|
|
topics = log.get("topics", [])
|
|
|
|
if len(topics) < 3:
|
|
continue
|
|
if topics[0].lower() != TRANSFER_EVENT_TOPIC0:
|
|
continue
|
|
if log.get("address", "").lower() != usdc_address:
|
|
continue
|
|
|
|
# Decode 'to' address from topic2
|
|
try:
|
|
to_address = "0x" + topics[2][-40:].lower()
|
|
except (IndexError, ValueError):
|
|
continue
|
|
|
|
if to_address != our_address:
|
|
continue
|
|
|
|
# Decode amount from data field
|
|
transfer_found = True
|
|
log_data = log.get("data", "0x")
|
|
try:
|
|
if log_data and log_data.startswith("0x") and len(log_data) >= 66:
|
|
actual_amount = str(int(log_data[:66], 16))
|
|
elif log_data:
|
|
actual_amount = str(int(log_data, 16))
|
|
except (ValueError, TypeError):
|
|
logger.warning(
|
|
f"Could not decode Transfer data for {tx_hash[:16]}...: {str(log_data)[:20]}"
|
|
)
|
|
|
|
# Verify amount matches
|
|
if expected_amount and actual_amount:
|
|
if actual_amount == expected_amount:
|
|
amount_match = True
|
|
else:
|
|
logger.warning(
|
|
f"Amount mismatch for {tx_hash[:16]}...: "
|
|
f"expected {expected_amount} atoms, got {actual_amount} atoms on {chain_key}"
|
|
)
|
|
elif actual_amount:
|
|
# Check if amount matches any tool price
|
|
for _tid, pricing in TOOL_PRICES.items():
|
|
if str(pricing.get("price_atoms")) == actual_amount:
|
|
amount_match = True
|
|
break
|
|
|
|
break # Found Transfer to our address
|
|
|
|
if not transfer_found:
|
|
return {
|
|
"verified": False,
|
|
"reason": f"No USDC Transfer to {our_address[:10]}... found in {tx_hash[:16]}...",
|
|
}
|
|
|
|
if not amount_match and expected_amount and actual_amount:
|
|
return {
|
|
"verified": False,
|
|
"reason": f"Amount mismatch: expected {expected_amount} atoms, got {actual_amount} atoms",
|
|
}
|
|
|
|
# Mark as spent in Redis with 86400s (24h) TTL - update the existing NX lock
|
|
tool_name = tool_id or "unknown"
|
|
if r:
|
|
spent_key = f"x402:spent_tx:{tx_hash}"
|
|
payment_data = {
|
|
"chain": chain_key,
|
|
"payer": payer or "unknown",
|
|
"amount": actual_amount or amount_atoms or "0",
|
|
"tool": tool_name,
|
|
"timestamp": time.time(),
|
|
}
|
|
r.setex(spent_key, 86400, json.dumps(payment_data))
|
|
|
|
# Persist to Supabase (non-blocking)
|
|
try:
|
|
import asyncio
|
|
|
|
from app.routers.x402_dashboard import _persist_payment_to_supabase
|
|
|
|
asyncio.create_task(
|
|
_persist_payment_to_supabase(
|
|
tool=tool_name,
|
|
amount_atoms=str(actual_amount or amount_atoms or "0"),
|
|
chain=chain_key,
|
|
payer=payer or "unknown",
|
|
tx_hash=tx_hash,
|
|
status="fulfilled",
|
|
)
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
logger.info(
|
|
f"Verified USDC payment: {tx_hash[:16]}... on {chain_key} "
|
|
f"from {payer[:10] if payer else 'unknown'}... amount={actual_amount or 'unknown'} tool={tool_name}"
|
|
)
|
|
return {
|
|
"verified": True,
|
|
"chain": chain_key,
|
|
"tx_hash": tx_hash,
|
|
"payer": payer,
|
|
"amount": actual_amount or amount_atoms,
|
|
"method": "blockscout-verify",
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Self-verify error: {e}")
|
|
return {"verified": False, "reason": f"Verification error: {str(e)[:100]}"}
|
|
|
|
|
|
# ── Refund policy helpers ──
|
|
|
|
|
|
def _record_refundable_payment(
|
|
tx_hash: str, chain: str, payer: str, amount: str, tool: str, reason: str
|
|
):
|
|
"""Record a refundable payment in Redis when a tool returns empty data.
|
|
|
|
Stores in Redis key 'x402:refund:{tx_hash}' with 7-day TTL.
|
|
Actual USDC refund is a manual process - we send USDC back from whichever chain has funds.
|
|
"""
|
|
r = get_redis()
|
|
if not r:
|
|
logger.warning(f"Cannot record refund for {tx_hash}: Redis unavailable")
|
|
return
|
|
|
|
refund_key = f"x402:refund:{tx_hash}"
|
|
refund_data = {
|
|
"tx_hash": tx_hash,
|
|
"chain": chain or "unknown",
|
|
"payer": payer or "unknown",
|
|
"amount_atoms": str(amount) if amount else "0",
|
|
"tool": tool or "unknown",
|
|
"reason": reason,
|
|
"status": "refundable", # refundable -> requested -> processing -> completed
|
|
"flagged_at": time.time(),
|
|
"requested_at": None,
|
|
"processed_at": None,
|
|
}
|
|
try:
|
|
r.setex(refund_key, 7 * 86400, json.dumps(refund_data)) # 7-day TTL
|
|
logger.info(f"Recorded refundable payment: {tx_hash[:16]}... tool={tool} reason={reason}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to record refund for {tx_hash}: {e}")
|
|
|
|
|
|
# ── Trial tracking (Redis-based) ──
|
|
_redis_client: _redis_types.Redis | None | bool = None # bool = unavailable sentinel
|
|
|
|
|
|
async def x402_enforcement_middleware(request: Request, call_next) -> Response:
|
|
"""
|
|
[DEPRECATED] Use domain/x402/middleware.py instead.
|
|
x402 payment enforcement with free trial support.
|
|
Intercepts /api/v1/x402-tools/* and returns 402 if no valid payment and no trials.
|
|
|
|
This middleware is kept for backward compatibility. New routes should use
|
|
app.domain.x402.middleware.require_payment() decorator instead.
|
|
"""
|
|
path = request.url.path
|
|
|
|
# Only intercept x402-tools (DataBus handles its own payment via verify_x402_payment)
|
|
if not path.startswith("/api/v1/x402-tools/"):
|
|
return await call_next(request)
|
|
|
|
# Add deprecation header to response
|
|
response = await call_next(request)
|
|
response.headers["X-Deprecated"] = "true"
|
|
response.headers["X-Deprecated-Reason"] = "Use domain/x402/middleware.py instead"
|
|
return response
|
|
|
|
# Allow preflight
|
|
if request.method == "OPTIONS":
|
|
return await call_next(request)
|
|
|
|
# ── Internal bypass: trial GET-to-POST re-dispatch ──────────────
|
|
# When the middleware converts a trial GET to POST internally,
|
|
# it sets X-RMI-Internal header. Skip enforcement on these.
|
|
if request.headers.get("X-RMI-Internal") == "trial-granted":
|
|
response = await call_next(request)
|
|
response.headers["X-RMI-Trial"] = "true"
|
|
remaining_hdr = request.headers.get("X-RMI-Trial-Remaining", "0")
|
|
response.headers["X-RMI-Trial-Remaining"] = remaining_hdr
|
|
for k, v in SECURITY_HEADERS.items():
|
|
if k not in response.headers:
|
|
response.headers[k] = v
|
|
return response
|
|
|
|
# ── Free endpoints - always accessible, no payment required ──────────
|
|
# These MUST be checked before bot detection so AI agents can discover us.
|
|
# Discovery/catalog/framework endpoints are useless if bots can't reach them.
|
|
FREE_GET_PATHS = {
|
|
"/api/v1/x402-tools/discovery",
|
|
"/api/v1/x402-tools/frameworks",
|
|
"/api/v1/x402-tools/openai-tools",
|
|
"/api/v1/x402-tools/anthropic-tools",
|
|
"/api/v1/x402-tools/gemini-tools",
|
|
"/api/v1/x402-tools/langchain-tools",
|
|
"/api/v1/x402-tools/catalog",
|
|
"/api/v1/x402-tools/bundles",
|
|
"/api/v1/x402-tools/payment-methods",
|
|
"/api/v1/bulletin-board/x402/info",
|
|
}
|
|
FREE_PATHS = {
|
|
"/api/v1/x402-tools/discovery",
|
|
"/api/v1/x402-tools/frameworks",
|
|
"/api/v1/x402-tools/openai-tools",
|
|
"/api/v1/x402-tools/anthropic-tools",
|
|
"/api/v1/x402-tools/gemini-tools",
|
|
"/api/v1/x402-tools/langchain-tools",
|
|
"/api/v1/x402-tools/catalog",
|
|
"/api/v1/x402-tools/bundles",
|
|
"/api/v1/x402-tools/payment-methods",
|
|
"/api/v1/bulletin-board/x402/info",
|
|
"/api/v1/x402-tools/human-execute",
|
|
"/api/v1/x402-tools/cache/stats",
|
|
"/api/v1/x402-tools/cache/clear",
|
|
"/api/v1/x402-tools/stream/alerts",
|
|
"/api/v1/x402-tools/webhook_list",
|
|
}
|
|
# Also exempt all /api/v1/x402/ admin endpoints, /api/v1/developer/, /api/v1/analytics/, /api/v1/state/, /api/v1/status, /api/v1/webhooks/, and /api/v1/tools/ (changelog) endpoints
|
|
# Also exempt /api/v1/x402/facilitator-health/ endpoints
|
|
if (
|
|
path.rstrip("/") in FREE_PATHS
|
|
or path.startswith("/api/v1/x402/")
|
|
or path.startswith("/api/v1/developer/")
|
|
or path.startswith("/api/v1/analytics/")
|
|
or path.startswith("/api/v1/state/")
|
|
or path.startswith("/api/v1/status")
|
|
or path.startswith("/api/v1/webhooks/")
|
|
or path.startswith("/api/v1/tools/changelog")
|
|
or path.startswith("/api/v1/tools/version")
|
|
or path.startswith("/api/v1/tools/deprecated")
|
|
):
|
|
return await call_next(request)
|
|
# GET requests to free paths are always allowed (discovery for agents)
|
|
if request.method == "GET" and path.rstrip("/") in FREE_GET_PATHS:
|
|
return await call_next(request)
|
|
|
|
# ── Developer API Key Check (FREE tier) ─────────────────────
|
|
# If request has a valid developer API key, bypass payment and use free tier limits.
|
|
# This must be checked BEFORE payment enforcement and bot detection.
|
|
try:
|
|
from app.routers.developer_tier import check_developer_key
|
|
|
|
dev_result = await check_developer_key(request)
|
|
if dev_result is not None:
|
|
# This IS a developer key request
|
|
if dev_result.get("deny"):
|
|
status_code = dev_result.get("status_code", 429)
|
|
return JSONResponse(
|
|
status_code=status_code,
|
|
content={
|
|
"error": dev_result.get("error", "rate_limit_exceeded"),
|
|
"tier": dev_result.get("tier", "free"),
|
|
"message": dev_result.get("error", "Rate limit exceeded"),
|
|
"daily_limit": dev_result.get("daily_limit"),
|
|
"calls_today": dev_result.get("calls_today"),
|
|
"upgrade_url": "https://rugmunch.io/mcp-docs#pricing",
|
|
},
|
|
headers={
|
|
**SECURITY_HEADERS,
|
|
"X-RMI-Tier": dev_result.get("tier", "free"),
|
|
"X-RMI-Remaining": str(dev_result.get("remaining_today", 0)),
|
|
"Retry-After": str(dev_result.get("retry_after", 60)),
|
|
},
|
|
)
|
|
# Valid dev key - attach info to request state and bypass payment
|
|
request.state.developer_key = True
|
|
request.state.developer_tier = dev_result.get("tier", "free")
|
|
request.state.developer_email = dev_result.get("email", "")
|
|
# Continue to tool execution without payment check
|
|
response = await call_next(request)
|
|
response.headers["X-RMI-Tier"] = dev_result.get("tier", "free")
|
|
response.headers["X-RMI-Remaining"] = str(dev_result.get("remaining_today", 0))
|
|
return response
|
|
except ImportError:
|
|
pass # developer_tier module not available - fall through to payment
|
|
except Exception as e:
|
|
logger.warning(f"Developer key check failed: {e}")
|
|
# Fail open - don't block paying customers on dev tier errors
|
|
pass
|
|
|
|
# ── Bot / abuse detection ─────────────────────────────────────
|
|
# Only block bots on PAID tool endpoints, not discovery
|
|
user_agent = (request.headers.get("User-Agent", "") or "").lower()
|
|
|
|
# Block known bot/scanner user agents ONLY on paid endpoints
|
|
# Legitimate API clients (curl, python, etc.) are welcome - just need payment
|
|
SCANNER_AGENTS = [
|
|
"masscan",
|
|
"nmap",
|
|
"nikto",
|
|
"sqlmap",
|
|
"dirbuster",
|
|
"gobuster",
|
|
"zgrab",
|
|
"censysinspect",
|
|
]
|
|
if any(bot in user_agent for bot in SCANNER_AGENTS):
|
|
return JSONResponse(
|
|
status_code=403,
|
|
content={
|
|
"error": "Automated access requires x402 payment. Use x-pay header or a proper API client.",
|
|
"docs": "https://rugmunch.io/mcp-docs",
|
|
},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
|
|
# Rate limit: max 5 rapid same-tool requests per fingerprint per 60s (burst protection)
|
|
client_id = get_client_id(request)
|
|
# Extract tool name early for burst tracking
|
|
path_tool_id = path.rstrip("/").split("/")[-1] if path.count("/") >= 4 else "unknown"
|
|
r = get_redis()
|
|
if r and re.match(r"^[a-zA-Z0-9_]+$", path_tool_id):
|
|
burst_key = f"x402:burst:{client_id}:{path_tool_id}"
|
|
try:
|
|
BURST_LIMIT = int(os.getenv("X402_BURST_LIMIT", "5"))
|
|
BURST_WINDOW = int(os.getenv("X402_BURST_WINDOW", "60"))
|
|
pipe = r.pipeline()
|
|
pipe.incr(burst_key)
|
|
if not r.exists(burst_key):
|
|
pipe.expire(burst_key, BURST_WINDOW)
|
|
results = pipe.execute()
|
|
burst_count = results[0] if results else 0
|
|
if burst_count > BURST_LIMIT:
|
|
logger.warning(
|
|
f"Burst limit: {client_id} hit {burst_count} requests in {BURST_WINDOW}s"
|
|
)
|
|
return JSONResponse(
|
|
status_code=429,
|
|
content={
|
|
"error": f"Rate limited. Max {BURST_LIMIT} requests per {BURST_WINDOW}s per tool.",
|
|
"retry_after": BURST_WINDOW,
|
|
},
|
|
headers={**SECURITY_HEADERS, "Retry-After": str(BURST_WINDOW)},
|
|
)
|
|
except Exception:
|
|
pass # Don't block on burst tracking errors
|
|
|
|
# Free paths already checked above - flow continues to paid tool enforcement
|
|
# Extract tool name
|
|
tool_id = path.rstrip("/").split("/")[-1]
|
|
|
|
# Input validation: reject tool IDs with suspicious characters
|
|
if not re.match(r"^[a-zA-Z0-9_]+$", tool_id):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"error": "Invalid tool identifier"},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
|
|
client_id = get_client_id(request)
|
|
|
|
# ── Payment header parsing: support both v1 (x-pay) and v2 (PAYMENT-SIGNATURE) ──
|
|
# v2 specifies PAYMENT-SIGNATURE header (base64 JSON), v1 uses X-Pay or x-pay
|
|
pay_sig = request.headers.get("PAYMENT-SIGNATURE", "") or request.headers.get(
|
|
"Payment-Signature", ""
|
|
)
|
|
x_pay = request.headers.get("x-pay", "") or request.headers.get("X-Pay", "")
|
|
|
|
# Reject oversized payment headers (DoS protection)
|
|
if pay_sig and len(pay_sig) > 16384:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"error": "Payment header too large"},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
# Size check BEFORE parsing - prevent DoS on oversized headers
|
|
max_header_len = 8192
|
|
if (x_pay and len(x_pay) > max_header_len) or (pay_sig and len(pay_sig) > max_header_len):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"error": "Payment header too large"},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
|
|
# Parse payment payload from whichever header is present
|
|
# v2 PAYMENT-SIGNATURE takes priority over v1 x-pay
|
|
payload = None
|
|
if pay_sig:
|
|
payload = parse_x_pay_header(pay_sig) # parse_x_pay handles base64 too
|
|
if payload:
|
|
# v2 spec: payload should have top-level x402Version, accepted, payload, resource
|
|
# Normalize: make sure "accepted" is available at top level for our verifier
|
|
if "accepted" not in payload and "payload" in payload:
|
|
# This is a v2 PaymentPayload format - extract payment info
|
|
pass # The verify_payment_via_router already handles this format
|
|
elif x_pay:
|
|
payload = parse_x_pay_header(x_pay)
|
|
|
|
if payload:
|
|
# Validate payment payload structure
|
|
if not isinstance(payload, dict):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"error": "Invalid payment payload structure"},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
accepted = payload.get("accepted", {})
|
|
if not isinstance(accepted, dict):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"error": "Invalid accepted payment structure"},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
result = await verify_payment_via_router(payload)
|
|
if result.get("verified"):
|
|
# Payment OK - attach info and proceed
|
|
request.state.x402_verified = True
|
|
request.state.x402_payer = result.get("payer", "")
|
|
request.state.x402_chain = result.get("chain", "")
|
|
request.state.x402_tx_hash = result.get("tx_hash", "")
|
|
request.state.x402_amount = result.get("amount", "")
|
|
request.state.x402_method = result.get("method", "")
|
|
|
|
# Track facilitator payment success for health monitoring
|
|
facilitator = result.get("method", result.get("facilitator", ""))
|
|
if facilitator:
|
|
try:
|
|
from app.routers.facilitator_health import record_payment_success
|
|
|
|
record_payment_success(facilitator, True)
|
|
except Exception:
|
|
logger.debug(f"Could not record facilitator success for {facilitator}")
|
|
|
|
response = await call_next(request)
|
|
response.headers["X-RMI-Payment"] = "verified"
|
|
|
|
# ── x402 v2: PAYMENT-RESPONSE header (base64 SettlementResponse) ──
|
|
# The v2 spec requires a PAYMENT-RESPONSE header on 200 responses
|
|
# after successful settlement, containing base64 JSON:
|
|
# {success: true, transaction: "0x...", network: "eip155:8453", payer: "0x..."}
|
|
settlement_response = {
|
|
"success": True,
|
|
"transaction": result.get("tx_hash", ""),
|
|
"network": CHAIN_USDC.get(result.get("chain", ""), {}).get(
|
|
"network", result.get("chain", "")
|
|
),
|
|
"payer": result.get("payer", ""),
|
|
}
|
|
if result.get("amount"):
|
|
settlement_response["amount"] = result.get("amount")
|
|
try:
|
|
payment_response_b64 = _base64.b64encode(
|
|
json.dumps(settlement_response, separators=(",", ":")).encode()
|
|
).decode("ascii")
|
|
response.headers["PAYMENT-RESPONSE"] = payment_response_b64
|
|
except Exception as e:
|
|
logger.warning(f"Failed to set PAYMENT-RESPONSE header: {e}")
|
|
|
|
# Add security headers to successful responses too
|
|
for k, v in SECURITY_HEADERS.items():
|
|
if k not in response.headers:
|
|
response.headers[k] = v
|
|
|
|
# ── Refund policy: detect empty/no-data responses and auto-flag for refund ──
|
|
# If a paid tool returns no real data, the payment should be refundable.
|
|
# We record this in Redis; actual USDC refund is a manual process from our wallet.
|
|
_should_flag_refund = False
|
|
_refund_reason = ""
|
|
try:
|
|
# Read the response body to check for empty data
|
|
resp_body = b""
|
|
async for chunk in response.body_iterator:
|
|
resp_body += chunk
|
|
# Reconstruct response with the read body
|
|
response = Response(
|
|
content=resp_body,
|
|
status_code=response.status_code,
|
|
media_type=response.media_type,
|
|
headers={
|
|
k: v
|
|
for k, v in response.headers.items()
|
|
if k.lower() not in ("content-length", "transfer-encoding")
|
|
},
|
|
)
|
|
|
|
if response.status_code >= 400:
|
|
_should_flag_refund = True
|
|
_refund_reason = f"HTTP {response.status_code} error response"
|
|
elif resp_body:
|
|
try:
|
|
body_json = json.loads(resp_body)
|
|
# Heuristic: empty data detection
|
|
# No sources, no findings, empty result, or explicit error
|
|
_has_sources = bool(
|
|
body_json.get("sources_used") or body_json.get("sources")
|
|
)
|
|
_has_findings = bool(
|
|
body_json.get("findings") or body_json.get("results")
|
|
)
|
|
_has_data = bool(
|
|
body_json.get("data")
|
|
or body_json.get("result")
|
|
or body_json.get("report")
|
|
)
|
|
_has_error = bool(body_json.get("error"))
|
|
_is_empty = not (_has_sources or _has_findings or _has_data)
|
|
|
|
if _is_empty and not _has_error:
|
|
_should_flag_refund = True
|
|
_refund_reason = "Tool returned no data (empty response)"
|
|
elif _has_error and not _has_data:
|
|
_should_flag_refund = True
|
|
_refund_reason = (
|
|
f"Tool error: {str(body_json.get('error', ''))[:100]}"
|
|
)
|
|
except (json.JSONDecodeError, ValueError):
|
|
pass # Non-JSON body, treat as having data
|
|
except Exception as e:
|
|
logger.debug(f"Refund detection body read error: {e}")
|
|
|
|
if _should_flag_refund:
|
|
_record_refundable_payment(
|
|
tx_hash=request.state.x402_tx_hash or "unknown",
|
|
chain=request.state.x402_chain,
|
|
payer=request.state.x402_payer,
|
|
amount=request.state.x402_amount,
|
|
tool=tool_id,
|
|
reason=_refund_reason,
|
|
)
|
|
response.headers["X-RMI-Refund-Flagged"] = "true"
|
|
logger.info(
|
|
f"Flagged payment for refund: tx={request.state.x402_tx_hash} "
|
|
f"tool={tool_id} reason={_refund_reason}"
|
|
)
|
|
|
|
return response
|
|
|
|
# Payment verification failed - track failure for health monitoring
|
|
facilitator = result.get("method", result.get("facilitator", ""))
|
|
if facilitator:
|
|
try:
|
|
from app.routers.facilitator_health import record_payment_success
|
|
|
|
record_payment_success(facilitator, False)
|
|
except Exception:
|
|
pass
|
|
|
|
# No valid payment - check free trials
|
|
try:
|
|
can_trial, remaining = check_trial(tool_id, client_id, consume=False)
|
|
except Exception as e:
|
|
logger.error(f"Trial check failed for {tool_id}: {e}")
|
|
can_trial, remaining = False, 0
|
|
|
|
if can_trial:
|
|
# ── Consume the trial now (atomic INCR in Redis) ──
|
|
# The pre-check above used consume=False; now we actually consume it
|
|
try:
|
|
_, remaining_after = check_trial(tool_id, client_id, consume=True)
|
|
remaining = remaining_after
|
|
except Exception as e:
|
|
logger.error(f"Trial consumption failed for {tool_id}/{client_id}: {e}")
|
|
|
|
# Allow free trial execution
|
|
# ── Execute the tool directly via DataBus caching shield ──
|
|
# This eliminates the fragile httpx GET-to-POST roundtrip.
|
|
# All 127 tools are available through the DataBus - no need for
|
|
# internal HTTP bouncing through POST-only route handlers.
|
|
try:
|
|
from app.caching_shield.tool_data import td
|
|
|
|
# Build params from request (query params for GET, JSON body for POST)
|
|
params = {}
|
|
if request.method == "GET" and request.query_params:
|
|
params = dict(request.query_params)
|
|
elif request.method == "POST":
|
|
try:
|
|
params = await request.json()
|
|
except Exception:
|
|
params = {}
|
|
|
|
result = await td.call_tool(tool_id, params)
|
|
|
|
if result is not None:
|
|
# Trial execution succeeded - return the result
|
|
# Use raw Response (not JSONResponse) to avoid BaseHTTPMiddleware
|
|
# body_iterator consumption bug when returning from middleware directly.
|
|
response_body = json.dumps(result, default=str)
|
|
response = Response(
|
|
content=response_body,
|
|
status_code=200,
|
|
media_type="application/json",
|
|
headers={
|
|
"X-RMI-Trial": "true",
|
|
"X-RMI-Trial-Remaining": str(remaining),
|
|
**SECURITY_HEADERS,
|
|
},
|
|
)
|
|
return response
|
|
else:
|
|
# DataBus returned None - tool not available via DataBus.
|
|
# For GET requests: try httpx GET-to-POST conversion.
|
|
# For POST requests: fall through to original route handler.
|
|
if request.method == "GET" and request.query_params:
|
|
import httpx
|
|
|
|
query_params = dict(request.query_params)
|
|
base_url = str(request.url).split("?")[0]
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
resp = await client.post(
|
|
base_url,
|
|
json=query_params,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-RMI-Internal": "trial-granted",
|
|
"X-RMI-Trial": "true",
|
|
"X-RMI-Trial-Remaining": str(remaining),
|
|
"X-Device-Id": request.headers.get("x-device-id", ""),
|
|
},
|
|
)
|
|
if resp.status_code == 404:
|
|
# Tool has no POST route handler (DataBus-only tool that failed).
|
|
# Trial was consumed - return 200 with "no data" result.
|
|
# The client got their free call, data just wasn't available.
|
|
return Response(
|
|
content=json.dumps(
|
|
{
|
|
"tool": tool_id,
|
|
"result": "no_data_available",
|
|
"trial": True,
|
|
"trial_remaining": remaining,
|
|
"message": "Free trial used. Data not available for this address - try a real token address.",
|
|
}
|
|
),
|
|
status_code=200,
|
|
media_type="application/json",
|
|
headers={
|
|
"X-RMI-Trial": "true",
|
|
"X-RMI-Trial-Remaining": str(remaining),
|
|
**SECURITY_HEADERS,
|
|
},
|
|
)
|
|
response = Response(
|
|
content=resp.content,
|
|
status_code=resp.status_code,
|
|
media_type=resp.headers.get("content-type", "application/json"),
|
|
headers={
|
|
k: v
|
|
for k, v in resp.headers.items()
|
|
if k.lower() not in ("content-length", "transfer-encoding")
|
|
},
|
|
)
|
|
except Exception:
|
|
# httpx failed - return "no data" response (trial was consumed)
|
|
return Response(
|
|
content=json.dumps(
|
|
{
|
|
"tool": tool_id,
|
|
"result": "no_data_available",
|
|
"trial": True,
|
|
"trial_remaining": remaining,
|
|
}
|
|
),
|
|
status_code=200,
|
|
media_type="application/json",
|
|
headers={
|
|
"X-RMI-Trial": "true",
|
|
"X-RMI-Trial-Remaining": str(remaining),
|
|
**SECURITY_HEADERS,
|
|
},
|
|
)
|
|
else:
|
|
# POST request or GET without params - try call_next
|
|
response = await call_next(request)
|
|
# If call_next returns 404, the tool has no POST route handler.
|
|
# Trial was consumed - return "no data" response instead of 404.
|
|
if response.status_code == 404:
|
|
return Response(
|
|
content=json.dumps(
|
|
{
|
|
"tool": tool_id,
|
|
"result": "no_data_available",
|
|
"trial": True,
|
|
"trial_remaining": remaining,
|
|
"message": "Free trial used. Data not available for this address - try a real token address.",
|
|
}
|
|
),
|
|
status_code=200,
|
|
media_type="application/json",
|
|
headers={
|
|
"X-RMI-Trial": "true",
|
|
"X-RMI-Trial-Remaining": str(remaining),
|
|
**SECURITY_HEADERS,
|
|
},
|
|
)
|
|
response.headers["X-RMI-Trial"] = "true"
|
|
response.headers["X-RMI-Trial-Remaining"] = str(remaining)
|
|
for k, v in SECURITY_HEADERS.items():
|
|
if k not in response.headers:
|
|
response.headers[k] = v
|
|
return response
|
|
|
|
except ImportError:
|
|
logger.warning("DataBus caching shield not available, falling back to call_next")
|
|
response = await call_next(request)
|
|
response.headers["X-RMI-Trial"] = "true"
|
|
response.headers["X-RMI-Trial-Remaining"] = str(remaining)
|
|
for k, v in SECURITY_HEADERS.items():
|
|
if k not in response.headers:
|
|
response.headers[k] = v
|
|
return response
|
|
except Exception as e:
|
|
logger.error(f"Trial execution via DataBus failed for {tool_id}: {e}")
|
|
# Trial consumed but execution failed - refund the trial
|
|
try:
|
|
r = get_redis()
|
|
if r:
|
|
key = f"x402:trial:{client_id}:{tool_id}"
|
|
if r.exists(key):
|
|
r.decr(key) # Roll back the consumption only if key exists
|
|
except Exception:
|
|
pass
|
|
# Return 402 - trial should be refunded since we couldn't execute
|
|
return build_402_response(tool_id, client_id=client_id)
|
|
|
|
# No trials left - return 402
|
|
return build_402_response(tool_id, client_id=client_id)
|
|
|
|
|
|
# ── Discovery Endpoint ──
|
|
from fastapi import APIRouter # noqa: E402
|
|
|
|
discovery_router = APIRouter()
|
|
# ── Main enforcement router (wrapper) ──
|
|
router = APIRouter(prefix="/api/v1/x402", tags=["x402 Enforcement"])
|
|
|
|
# Note: discovery_router is included on the app directly in main.py at root path
|
|
# so .well-known/x402 resolves at /.well-known/x402 (x402 spec requirement)
|
|
|
|
|
|
def _build_discovery_response():
|
|
"""Build the full discovery response with all 13 chains and active facilitators."""
|
|
tools = {}
|
|
for tool_id, pricing in TOOL_PRICES.items():
|
|
try:
|
|
price_atoms = str(pricing.get("price_atoms", "10000"))
|
|
price_usd = float(pricing.get("price_usd", 0.01))
|
|
trial_free = int(pricing.get("trial_free", 1))
|
|
category = pricing.get("category", "analysis")
|
|
except (ValueError, TypeError):
|
|
continue
|
|
|
|
requirements = []
|
|
for chain_key, cfg in CHAIN_USDC.items():
|
|
method = cfg["method"]
|
|
pay_to = _resolve_pay_to(method)
|
|
asset = _resolve_asset(cfg)
|
|
extra = _build_extra(cfg, tool_id, chain_key, pay_to, method)
|
|
|
|
req = {
|
|
"scheme": "exact",
|
|
"network": cfg["network"],
|
|
"asset": asset,
|
|
"amount": price_atoms,
|
|
"payTo": pay_to,
|
|
"maxTimeoutSeconds": 180,
|
|
"extra": extra,
|
|
"facilitators": cfg.get("facilitators", []),
|
|
}
|
|
if "tokens" in cfg:
|
|
req["supportedTokens"] = list(cfg["tokens"].keys())
|
|
requirements.append(req)
|
|
|
|
desc = pricing.get("description", "")
|
|
if not desc:
|
|
_TOOL_DESCRIPTIONS = {
|
|
"forensic_valuation": "Institutional-grade token valuation - DCF intrinsic value, comparable analysis with outlier detection, scam probability scoring",
|
|
"osint_identity_hunt": "Cross-platform OSINT - hunt usernames across 400+ networks, domain intelligence, stealth page capture",
|
|
"investigation_report": "Full investigation report - on-chain forensics, financial valuation, OSINT, scam scoring in one deliverable",
|
|
"forensic_pack": "Forensic Investigation Pack - valuation + OSINT + report at 33% discount",
|
|
}
|
|
desc = _TOOL_DESCRIPTIONS.get(tool_id, f"{tool_id} - crypto intelligence tool")
|
|
tools[tool_id] = {
|
|
"description": desc,
|
|
"price_usd": price_usd,
|
|
"category": category,
|
|
"trial_free": trial_free,
|
|
"trial_description": f"{trial_free} free calls before payment required",
|
|
"requirements": requirements,
|
|
}
|
|
|
|
# Build spec-compliant supportedNetworks array
|
|
supported_networks = []
|
|
for ck, cv in CHAIN_USDC.items():
|
|
net = {
|
|
"network": cv["network"],
|
|
"name": CHAIN_NAMES.get(ck, ck),
|
|
"chainId": cv.get("chain_id"),
|
|
"currency": cv.get("name", "USD Coin"),
|
|
"facilitators": cv.get("facilitators", []),
|
|
}
|
|
if "tokens" in cv:
|
|
net["tokens"] = dict(cv["tokens"].items())
|
|
supported_networks.append(net)
|
|
|
|
# Build spec-compliant paymentFacilitators array
|
|
facilitator_map = {
|
|
"coinbase_cdp": {
|
|
"name": "Coinbase CDP",
|
|
"description": "Fee-free Base + Solana USDC via Coinbase Developer Platform",
|
|
"url": "https://cdp.coinbase.com",
|
|
},
|
|
"payai": {
|
|
"name": "PayAI",
|
|
"description": "Base + Solana USDC, deferred settlement",
|
|
"url": "https://payai.network",
|
|
},
|
|
"primev": {
|
|
"name": "Primev",
|
|
"description": "Fee-free Ethereum via mev-commit preconfirmations",
|
|
"url": "https://primev.xyz",
|
|
},
|
|
"cloudflare_x402": {
|
|
"name": "Cloudflare x402",
|
|
"description": "Base Sepolia + Ethereum fallback",
|
|
"url": "https://x402.org",
|
|
},
|
|
"eip7702": {
|
|
"name": "EIP-7702 Universal",
|
|
"description": "Universal EVM - BSC, Polygon, Avalanche, Fantom, Gnosis, Arbitrum, Optimism, Base",
|
|
"url": "https://eips.ethereum.org/EIPS/eip-7702",
|
|
},
|
|
"tron_selfverify": {
|
|
"name": "TRON Self-Verify",
|
|
"description": "TRON USDT/USDC/USDD - self-verified via TronGrid (fee-free)",
|
|
"url": "https://trongrid.io",
|
|
},
|
|
"bitcoin_selfverify": {
|
|
"name": "Bitcoin Self-Verify",
|
|
"description": "Bitcoin BTC - self-verified via Mempool.space (fee-free, 1-conf)",
|
|
"url": "https://mempool.space",
|
|
},
|
|
"asterpay": {
|
|
"name": "AsterPay",
|
|
"description": "EUR/SEPA European off-ramp",
|
|
"url": "https://asterpay.io",
|
|
},
|
|
}
|
|
payment_facilitators = []
|
|
for fk, fv in facilitator_map.items():
|
|
fac_networks = [n["network"] for n in supported_networks if fk in n.get("facilitators", [])]
|
|
payment_facilitators.append(
|
|
{
|
|
"name": fv["name"],
|
|
"description": fv["description"],
|
|
"url": fv["url"],
|
|
"supportedNetworks": fac_networks,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"x402": {
|
|
"version": "2",
|
|
"protocol": "x402",
|
|
"description": "Rug Munch Intelligence (RMI) - Multi-chain x402 payment system. Crypto scam detection, market analysis, and security intelligence via micropayments.",
|
|
"trial_policy": "1 free trial per tool without wallet. Connect wallet for 3 free calls per standard tool, 1 per premium tool.",
|
|
"refund_policy": "Full refund if tool returns no data. Request within 48h via POST /api/v1/x402/refund with tx hash.",
|
|
"facilitator_summary": {
|
|
"coinbase_cdp": "Fee-free Base + Solana USDC via Coinbase Developer Platform",
|
|
"payai": "Base + Solana USDC, deferred settlement",
|
|
"primev": "Fee-free Ethereum via mev-commit preconfirmations",
|
|
"cloudflare_x402": "Base Sepolia + Ethereum fallback",
|
|
"eip7702": "Universal EVM - BSC, Polygon, Avalanche, Fantom, Gnosis, Arbitrum, Optimism, Base",
|
|
"tron_selfverify": "TRON USDT/USDC/USDD - self-verified via TronGrid (fee-free)",
|
|
"bitcoin_selfverify": "Bitcoin BTC - self-verified via Mempool.space (fee-free, 1-conf)",
|
|
"asterpay": "EUR/SEPA European off-ramp",
|
|
"x402_rs": "Self-hosted x402-rs - multi-chain (requires Docker)",
|
|
},
|
|
},
|
|
"gateway_url": "https://mcp.rugmunch.io",
|
|
"payment_endpoint": "https://mcp.rugmunch.io/api/v1/x402-tools",
|
|
"supported_chains": list(CHAIN_USDC.keys()),
|
|
"supportedNetworks": supported_networks,
|
|
"paymentFacilitators": payment_facilitators,
|
|
"chain_count": len(CHAIN_USDC),
|
|
"facilitator_count": len(payment_facilitators),
|
|
"total_tools": len(tools),
|
|
"tools": tools,
|
|
}
|
|
|
|
|
|
def _resolve_pay_to(method: str) -> str:
|
|
_ensure_pay_addresses()
|
|
if method == "payai":
|
|
return SOL_PAY_TO or "" # type: ignore[return-value]
|
|
elif method == "bitcoin_selfverify":
|
|
return os.getenv("X402_BTC_PAY_TO", "")
|
|
elif method == "tron_selfverify":
|
|
return os.getenv("X402_TRON_PAY_TO", "")
|
|
elif method == "asterpay":
|
|
return os.getenv("ASTERPAY_SEPA_IBAN", "")
|
|
return EVM_PAY_TO or "" # type: ignore[return-value]
|
|
|
|
|
|
def _resolve_asset(cfg: dict) -> str:
|
|
asset = cfg.get("usdc", "")
|
|
if not asset and "tokens" in cfg:
|
|
first = next(iter(cfg["tokens"].values()), "")
|
|
return first if first != "native" else "BTC"
|
|
return asset
|
|
|
|
|
|
def _build_extra(cfg: dict, tool_id: str, chain_key: str, pay_to: str, method: str) -> dict:
|
|
extra = {
|
|
"name": cfg["name"],
|
|
"version": cfg["version"],
|
|
"tool": tool_id,
|
|
"chain": chain_key,
|
|
}
|
|
if method == "local_eip712" and cfg.get("chain_id"):
|
|
extra["domain"] = {
|
|
"name": cfg["name"],
|
|
"version": cfg["version"],
|
|
"chainId": cfg["chain_id"],
|
|
"verifyingContract": pay_to,
|
|
}
|
|
elif method == "payai":
|
|
extra["feePayer"] = "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4"
|
|
elif method == "tron_selfverify":
|
|
extra["tronNetwork"] = "mainnet"
|
|
extra["trc20Tokens"] = cfg.get("tokens", {})
|
|
elif method == "bitcoin_selfverify":
|
|
extra["paymentNetwork"] = "bitcoin"
|
|
extra["settlementChains"] = ["base", "solana"]
|
|
elif method == "asterpay":
|
|
extra["currency"] = "EUR"
|
|
extra["sepa"] = True
|
|
return extra
|
|
|
|
|
|
@discovery_router.get("/.well-known/x402")
|
|
async def x402_discovery():
|
|
"""x402 discovery endpoint - lists all tools, chains, payment requirements, and trial info.
|
|
Response is cached for DISCOVERY_CACHE_TTL seconds to avoid rebuilding 7-chain requirements per request.
|
|
"""
|
|
global _discovery_cache, _discovery_cache_time
|
|
|
|
now = time.time()
|
|
if _discovery_cache is not None and (now - _discovery_cache_time) < DISCOVERY_CACHE_TTL:
|
|
return _discovery_cache
|
|
|
|
_discovery_cache = _build_discovery_response()
|
|
_discovery_cache_time = now
|
|
return _discovery_cache
|
|
|
|
|
|
# ── Trial Status Endpoint ──
|
|
@discovery_router.get("/api/v1/x402/trial-status")
|
|
async def get_trial_status(request: Request):
|
|
"""Check remaining free trials for the current client"""
|
|
client_id = get_client_id(request)
|
|
trials = {}
|
|
for tool_id, pricing in TOOL_PRICES.items():
|
|
max_trials = pricing.get("trial_free", 3)
|
|
if max_trials > 0:
|
|
r = get_redis()
|
|
if r:
|
|
try:
|
|
used = int(r.get(f"x402:trial:{client_id}:{tool_id}") or 0)
|
|
except Exception:
|
|
used = 0
|
|
else:
|
|
used = 0
|
|
trials[tool_id] = {
|
|
"max": max_trials,
|
|
"used": used,
|
|
"remaining": max(0, max_trials - used),
|
|
}
|
|
|
|
return {
|
|
"client_id": client_id[:20] + "..." if len(client_id) > 20 else client_id,
|
|
"trials": trials,
|
|
"tools_with_trials": len(trials),
|
|
}
|
|
|
|
|
|
# ── Revenue Read Endpoint ──
|
|
@discovery_router.get("/api/v1/x402/revenue")
|
|
async def get_revenue(request: Request = None):
|
|
"""Read cumulative x402 revenue stats from Redis counters.
|
|
|
|
AUTH: Requires X-API-Key matching ADMIN_API_KEY.
|
|
|
|
Returns total revenue, daily breakdown, tool-level stats, and payment counts.
|
|
Revenue counters are incremented by POST /api/v1/x402/receipt on successful payments.
|
|
"""
|
|
_ensure_pay_addresses()
|
|
if request:
|
|
admin_key = os.getenv("ADMIN_API_KEY", "")
|
|
if admin_key:
|
|
auth = request.headers.get("X-API-Key", "") or request.headers.get("Authorization", "").replace("Bearer ", "")
|
|
if auth != admin_key:
|
|
return JSONResponse(status_code=403, content={"error": "Admin API key required"})
|
|
r = get_redis()
|
|
if not r:
|
|
return JSONResponse(
|
|
status_code=503,
|
|
content={"error": "Redis unavailable, cannot read revenue"},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
|
|
try:
|
|
total_revenue = float(r.get("x402:revenue:total") or 0)
|
|
total_calls = 0
|
|
tool_revenue = {}
|
|
tool_calls = {}
|
|
|
|
# Scan tool-level keys
|
|
for key in r.scan_iter("x402:tool_revenue:*"):
|
|
tool_id = key.decode().replace("x402:tool_revenue:", "")
|
|
tool_revenue[tool_id] = float(r.get(key) or 0)
|
|
|
|
for key in r.scan_iter("x402:tool_calls:*"):
|
|
tool_id = key.decode().replace("x402:tool_calls:", "")
|
|
count = int(r.get(key) or 0)
|
|
tool_calls[tool_id] = count
|
|
total_calls += count
|
|
|
|
# Daily breakdown (last 30 days)
|
|
daily = {}
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
for i in range(30):
|
|
day = (datetime.utcnow() - timedelta(days=i)).strftime("%Y-%m-%d")
|
|
val = float(r.get(f"x402:revenue:daily:{day}") or 0)
|
|
if val > 0:
|
|
daily[day] = val
|
|
|
|
return {
|
|
"total_revenue_usd": round(total_revenue, 2),
|
|
"total_tool_calls": total_calls,
|
|
"by_tool": {
|
|
tid: {
|
|
"revenue_usd": round(tool_revenue.get(tid, 0), 2),
|
|
"calls": tool_calls.get(tid, 0),
|
|
}
|
|
for tid in set(list(tool_revenue.keys()) + list(tool_calls.keys()))
|
|
},
|
|
"daily": daily,
|
|
"payment_wallets": {
|
|
"evm": EVM_PAY_TO,
|
|
"solana": SOL_PAY_TO,
|
|
"tron": os.getenv("X402_TRON_PAY_TO", ""),
|
|
"bitcoin": os.getenv("X402_BTC_PAY_TO", ""),
|
|
},
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Revenue read error: {e}")
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"error": f"Failed to read revenue: {str(e)[:100]}"},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
|
|
|
|
# ── #8 Auto-Pricing Suggestions ──────────────────────────────────────
|
|
|
|
|
|
@discovery_router.get("/api/v1/x402/analytics/pricing-suggestions")
|
|
async def get_pricing_suggestions():
|
|
"""Auto-pricing suggestions based on conversion rates and usage data.
|
|
|
|
Algorithm:
|
|
- Pull call counts + revenue + trial counts per tool from Redis
|
|
- Compute conversion rate = paid_calls / (paid_calls + trial_calls)
|
|
- Tools with <5% conversion after 100+ trials → PRICE TOO HIGH → suggest decrease
|
|
- Tools with >40% conversion → PRICE TOO LOW → suggest increase
|
|
- Tools with 0 calls in 7 days → DEAD → suggest archive
|
|
"""
|
|
r = get_redis()
|
|
if not r:
|
|
return JSONResponse(
|
|
status_code=503,
|
|
content={"error": "Redis unavailable"},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
try:
|
|
suggestions = []
|
|
for tool_id, pricing in TOOL_PRICES.items():
|
|
current_price = pricing.get("price_usd", 0)
|
|
trial_free = pricing.get("trial_free", 3)
|
|
|
|
# Get usage stats from Redis
|
|
paid_calls = int(r.get(f"x402:tool_calls:{tool_id}") or 0)
|
|
trial_calls = int(r.get(f"x402:trial_calls:{tool_id}") or 0)
|
|
revenue = float(r.get(f"x402:tool_revenue:{tool_id}") or 0)
|
|
|
|
total_calls = paid_calls + trial_calls
|
|
conversion_rate = paid_calls / total_calls if total_calls > 0 else 0
|
|
avg_revenue_per_call = revenue / paid_calls if paid_calls > 0 else 0
|
|
|
|
suggestion = None
|
|
suggested_price = current_price
|
|
|
|
if total_calls < 10:
|
|
suggestion = "insufficient_data"
|
|
elif total_calls > 100 and conversion_rate < 0.05:
|
|
suggestion = "decrease_price"
|
|
suggested_price = round(current_price * 0.6, 3)
|
|
elif conversion_rate > 0.40:
|
|
suggestion = "increase_price"
|
|
suggested_price = round(current_price * 1.3, 3)
|
|
elif total_calls > 50 and conversion_rate < 0.15:
|
|
suggestion = "consider_decrease"
|
|
suggested_price = round(current_price * 0.8, 3)
|
|
elif paid_calls == 0 and trial_calls > trial_free * 10:
|
|
suggestion = "price_barrier"
|
|
suggested_price = round(current_price * 0.5, 3)
|
|
elif revenue > 0 and avg_revenue_per_call < current_price * 0.5:
|
|
suggestion = "check_pricing_consistency"
|
|
|
|
suggestions.append(
|
|
{
|
|
"tool_id": tool_id,
|
|
"current_price_usd": current_price,
|
|
"suggested_price_usd": suggested_price,
|
|
"suggestion": suggestion,
|
|
"stats": {
|
|
"paid_calls": paid_calls,
|
|
"trial_calls": trial_calls,
|
|
"total_calls": total_calls,
|
|
"conversion_rate": round(conversion_rate * 100, 1),
|
|
"revenue_usd": round(revenue, 4),
|
|
"avg_revenue_per_call": round(avg_revenue_per_call, 4),
|
|
},
|
|
}
|
|
)
|
|
|
|
# Sort: actionable suggestions first
|
|
suggestions.sort(
|
|
key=lambda s: 0 if s["suggestion"] and s["suggestion"] != "insufficient_data" else 1
|
|
)
|
|
|
|
return {
|
|
"suggestions": suggestions,
|
|
"total_tools": len(suggestions),
|
|
"actionable": sum(
|
|
1 for s in suggestions if s["suggestion"] and s["suggestion"] != "insufficient_data"
|
|
),
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Pricing suggestions error: {e}")
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"error": str(e)[:100]},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
|
|
|
|
# ── Refund Request Endpoint ──
|
|
def _verify_refund_ownership(refund_payer: str, tx_hash: str) -> bool:
|
|
"""Verify the requester controls the payer address by checking on-chain.
|
|
|
|
Proof-of-ownership: The requester must sign a message with the payer
|
|
wallet. We verify by checking that the payer address matches the one
|
|
stored in our payment records for this transaction.
|
|
|
|
Without this check, anyone who knows a tx_hash could claim a refund
|
|
for someone else's payment.
|
|
|
|
Production: require a signed message (EIP-191 / Solana) proving
|
|
wallet ownership. For v1, we verify the payer matches our records.
|
|
"""
|
|
r = get_redis()
|
|
if not r:
|
|
return False
|
|
|
|
spent_data = r.get(f"x402:spent_tx:{tx_hash}")
|
|
if not spent_data:
|
|
return False
|
|
|
|
try:
|
|
spent_info = json.loads(spent_data)
|
|
recorded_payer = spent_info.get("payer", "").lower()
|
|
return bool(refund_payer and recorded_payer and refund_payer.lower() == recorded_payer)
|
|
except (json.JSONDecodeError, TypeError, AttributeError):
|
|
return False
|
|
|
|
|
|
@router.post("/refund")
|
|
async def request_refund(request: Request):
|
|
"""Request a refund for a paid tool that returned no data.
|
|
|
|
Validates:
|
|
(a) tx exists in our payment records (x402:spent_tx or x402:refund)
|
|
(b) Proof of ownership: requester controls the payer address
|
|
(c) within 48h of original payment
|
|
|
|
If valid, records the refund request. Actual USDC refund is manual.
|
|
|
|
POST body: {
|
|
"tx_hash": "0x...",
|
|
"payer": "0x..." (REQUIRED - your wallet address that paid),
|
|
"signature": "0x..." (optional - EIP-191 signed message for v2),
|
|
"reason": "Tool returned empty data" (optional)
|
|
}
|
|
"""
|
|
try:
|
|
body = await request.json()
|
|
tx_hash = body.get("tx_hash", "").strip()
|
|
requester_payer = body.get("payer", "").strip()
|
|
requester_reason = body.get("reason", "").strip()
|
|
|
|
if not tx_hash:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"error": "tx_hash is required"},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
|
|
if not requester_payer:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"error": "payer address is required (proof of ownership)"},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
|
|
# Proof of ownership: verify requester controls the payer address
|
|
if not _verify_refund_ownership(requester_payer, tx_hash):
|
|
return JSONResponse(
|
|
status_code=403,
|
|
content={
|
|
"error": "Proof of ownership failed. The payer address does not match our records for this transaction.",
|
|
"tx_hash": tx_hash[:16] + "...",
|
|
"note": "Provide the wallet address that originally paid for this transaction.",
|
|
},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
|
|
r = get_redis()
|
|
if not r:
|
|
return JSONResponse(
|
|
status_code=503,
|
|
content={"error": "Redis unavailable, cannot process refund request"},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
|
|
# (a) Check if tx exists in our payment records
|
|
spent_data = r.get(f"x402:spent_tx:{tx_hash}")
|
|
existing_refund = r.get(f"x402:refund:{tx_hash}")
|
|
|
|
if not spent_data and not existing_refund:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={
|
|
"error": "Transaction not found in payment records",
|
|
"tx_hash": tx_hash[:16] + "...",
|
|
},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
|
|
# If there's already a refund record, check its status
|
|
if existing_refund:
|
|
try:
|
|
refund_info = json.loads(existing_refund)
|
|
status = refund_info.get("status", "unknown")
|
|
if status in ("requested", "processing", "completed"):
|
|
return {
|
|
"status": "already_requested",
|
|
"refund_status": status,
|
|
"tx_hash": tx_hash[:16] + "...",
|
|
"message": f"Refund already {status}",
|
|
"amount_atoms": refund_info.get("amount_atoms"),
|
|
"chain": refund_info.get("chain"),
|
|
}
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
|
|
# (b) Check if payment was flagged as refundable (tool returned no data/error)
|
|
refund_reason = requester_reason or "User-requested refund"
|
|
refund_chain = "unknown"
|
|
refund_payer = requester_payer or "unknown"
|
|
refund_amount = "0"
|
|
payment_timestamp = None
|
|
|
|
if existing_refund:
|
|
try:
|
|
refund_info = json.loads(existing_refund)
|
|
if refund_info.get("status") == "refundable":
|
|
refund_reason = refund_info.get("reason") or refund_reason
|
|
refund_chain = refund_info.get("chain", "unknown")
|
|
refund_payer = refund_info.get("payer", requester_payer)
|
|
refund_amount = refund_info.get("amount_atoms", "0")
|
|
payment_timestamp = refund_info.get("flagged_at")
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
elif spent_data:
|
|
# Payment exists but wasn't auto-flagged - user is requesting manually
|
|
# Allow if within 48h window and user provides a reason
|
|
try:
|
|
spent_info = json.loads(spent_data)
|
|
refund_chain = spent_info.get("chain", "unknown")
|
|
refund_payer = spent_info.get("payer", requester_payer)
|
|
refund_amount = spent_info.get("amount", "0")
|
|
payment_timestamp = spent_info.get("timestamp")
|
|
except (json.JSONDecodeError, TypeError):
|
|
# Old format: spent_data was just the chain name string
|
|
refund_chain = spent_data if isinstance(spent_data, str) else "unknown"
|
|
|
|
# (c) Verify within 48h of original payment
|
|
if payment_timestamp:
|
|
hours_since_payment = (time.time() - float(payment_timestamp)) / 3600
|
|
if hours_since_payment > 48:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "Refund window expired (48h)",
|
|
"hours_since_payment": round(hours_since_payment, 1),
|
|
"tx_hash": tx_hash[:16] + "...",
|
|
},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
|
|
# Record the refund request
|
|
refund_key = f"x402:refund:{tx_hash}"
|
|
refund_data = {
|
|
"tx_hash": tx_hash,
|
|
"chain": refund_chain,
|
|
"payer": refund_payer,
|
|
"amount_atoms": str(refund_amount),
|
|
"tool": "unknown",
|
|
"reason": refund_reason,
|
|
"status": "requested",
|
|
"flagged_at": payment_timestamp,
|
|
"requested_at": time.time(),
|
|
"processed_at": None,
|
|
}
|
|
r.setex(refund_key, 7 * 86400, json.dumps(refund_data)) # 7-day TTL
|
|
|
|
logger.info(
|
|
f"Refund requested: tx={tx_hash[:16]}... chain={refund_chain} "
|
|
f"amount={refund_amount} reason={refund_reason}"
|
|
)
|
|
|
|
return {
|
|
"status": "requested",
|
|
"tx_hash": tx_hash[:16] + "...",
|
|
"chain": refund_chain,
|
|
"amount_atoms": str(refund_amount),
|
|
"reason": refund_reason,
|
|
"message": "Refund request recorded. Manual processing within 48h. You will receive USDC back on the original payment chain.",
|
|
"estimated_processing": "24-48 hours",
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Refund request error: {e}")
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"error": f"Refund request failed: {str(e)[:100]}"},
|
|
headers=SECURITY_HEADERS,
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# X402Enforcer class — thin orchestrator over the module-level middleware.
|
|
# Phase 3B of AUDIT-2026-Q3.md refactor.
|
|
#
|
|
# Provides a class-based API for callers that prefer OO style. All actual
|
|
# state and routing logic remains in the module-level functions above.
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class X402Enforcer:
|
|
"""Class-based facade for x402 payment enforcement.
|
|
|
|
Wraps the procedural middleware in app.billing.x402.enforcement
|
|
so call sites can use an instance API:
|
|
|
|
enforcer = X402Enforcer()
|
|
response = await enforcer.enforce(request, call_next)
|
|
|
|
All state is still module-level (TOOL_PRICES, CHAIN_USDC, VERIFIER),
|
|
matching the legacy behavior of x402_enforcement_middleware().
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.tool_prices = TOOL_PRICES
|
|
self.chain_usdc = CHAIN_USDC
|
|
self.security_headers = SECURITY_HEADERS
|
|
self._verifier_loaded = False
|
|
|
|
async def enforce(self, request: Request, call_next):
|
|
"""Run the x402 payment enforcement middleware."""
|
|
return await x402_enforcement_middleware(request, call_next)
|
|
|
|
def check_trial(self, tool_id: str, client_id: str, max_trials: int = 3):
|
|
return check_trial(tool_id, client_id, max_trials)
|
|
|
|
def consume_trial(self, tool_id: str, client_id: str, max_trials: int = 3):
|
|
return consume_trial(tool_id, client_id, max_trials)
|
|
|
|
async def verify_payment(self, payload: dict) -> dict:
|
|
return await verify_payment(payload)
|
|
|
|
def build_402_response(self, tool_id: str, client_id: str = ""):
|
|
return build_402_response(tool_id, client_id)
|
|
|
|
def ensure_tool_prices(self) -> None:
|
|
return _ensure_tool_prices()
|
|
|
|
def ensure_pay_addresses(self) -> None:
|
|
return _ensure_pay_addresses()
|
|
|
|
|
|
__all__ = [
|
|
"X402Enforcer",
|
|
"TOOL_PRICES",
|
|
"CHAIN_USDC",
|
|
"SECURITY_HEADERS",
|
|
"check_idempotency",
|
|
"check_trial",
|
|
"consume_trial",
|
|
"build_402_response",
|
|
"parse_x_pay_header",
|
|
"verify_payment",
|
|
"verify_payment_via_router",
|
|
"self_verify_evm_usdc",
|
|
"x402_enforcement_middleware",
|
|
"x402_discovery",
|
|
"get_trial_status",
|
|
"get_revenue",
|
|
"get_pricing_suggestions",
|
|
"request_refund",
|
|
"_ensure_tool_prices",
|
|
"_ensure_pay_addresses",
|
|
"_ensure_verifier",
|
|
"_get_pay_to_address",
|
|
"_load_tool_prices",
|
|
"_build_accepts_list",
|
|
"_build_bazaar_extension",
|
|
"_build_resource_info",
|
|
"_build_discovery_response",
|
|
"_resolve_pay_to",
|
|
"_resolve_asset",
|
|
"_build_extra",
|
|
"_record_refundable_payment",
|
|
"_verify_refund_ownership",
|
|
"_detect_token_from_asset",
|
|
"get_redis_async",
|
|
"EVM_PAY_TO",
|
|
"SOL_PAY_TO",
|
|
"VERIFIER",
|
|
]
|