859 lines
31 KiB
Python
859 lines
31 KiB
Python
"""
|
|
RMI x402 Payment Middleware - Proper x402 Protocol Integration
|
|
================================================================
|
|
Uses the official x402 SDK for payment verification, receipt validation,
|
|
and public transparency. Replaces the hacky direct facilitator POST.
|
|
|
|
Components:
|
|
1. x402_middleware.py - FastAPI middleware for payment verification
|
|
2. /api/v1/x402/receipt/{id}/verify - Public receipt verification
|
|
3. /api/v1/x402/ledger - Public payment ledger (anonymized)
|
|
4. /api/v1/x402/transparency - System trust metrics
|
|
|
|
Author: RMI Development
|
|
Date: 2026-05-03
|
|
"""
|
|
|
|
import contextlib
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
logger = logging.getLogger("x402_middleware")
|
|
|
|
router = APIRouter(prefix="/api/v1/x402", tags=["x402-payment-middleware"])
|
|
|
|
|
|
# ── x402 SDK Integration ──────────────────────────────────────
|
|
|
|
# Import x402 SDK components
|
|
try:
|
|
from x402 import (
|
|
X402_VERSION,
|
|
Network, # noqa: F401
|
|
PaymentPayload, # noqa: F401
|
|
PaymentRequirements, # noqa: F401
|
|
parse_payment_payload, # noqa: F401
|
|
parse_payment_required, # noqa: F401
|
|
x402Facilitator, # noqa: F401
|
|
)
|
|
|
|
X402_SDK_AVAILABLE = True
|
|
logger.info(f"x402 SDK v{X402_VERSION} loaded")
|
|
except ImportError as e:
|
|
X402_SDK_AVAILABLE = False
|
|
logger.warning(f"x402 SDK not available: {e}")
|
|
|
|
|
|
# ── Payment Verification Engine ────────────────────────────────
|
|
|
|
# ERC-20 token metadata for EIP-712 domain verification
|
|
# Maps (chain_id, token_address) -> (name, version)
|
|
TOKEN_METADATA = {
|
|
# USDC on Base mainnet
|
|
(8453, "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"): ("USD Coin", "2"),
|
|
# USDC on Base Sepolia
|
|
(84532, "0x036cbd53842c5426634e7929541ec2318f3dcf7e"): ("USD Coin", "2"),
|
|
}
|
|
|
|
# Network configs with correct facilitator URLs
|
|
FACILITATOR_CONFIGS = {
|
|
"base": {
|
|
"network": "eip155:8453",
|
|
"chain_id": 8453,
|
|
# x402.org doesn't support Base mainnet, use PayAI
|
|
"url": "https://facilitator.payai.network",
|
|
},
|
|
"base-sepolia": {
|
|
"network": "eip155:84532",
|
|
"chain_id": 84532,
|
|
"url": "https://x402.org/facilitator",
|
|
},
|
|
"solana": {
|
|
"network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
|
|
"url": "https://facilitator.payai.network",
|
|
},
|
|
"solana-devnet": {
|
|
"network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
|
|
"url": "https://facilitator.payai.network",
|
|
},
|
|
}
|
|
|
|
|
|
class PaymentVerifier:
|
|
"""
|
|
x402 payment verification with local EIP-712 verification for Base
|
|
and facilitator fallback for other networks.
|
|
|
|
Primary: Local EIP-712 signature verification (no external dependency)
|
|
Fallback: PayAI facilitator via HTTP
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._facilitators: dict[str, Any] = {}
|
|
|
|
def _verify_eip712_local(self, payload_data: dict) -> dict[str, Any]:
|
|
"""
|
|
Verify EIP-712 signed EIP-3009 authorization locally.
|
|
No external facilitator needed - pure cryptographic verification.
|
|
Uses the name/version from the payment's extra field (matches what SDK signed with).
|
|
"""
|
|
try:
|
|
from eth_account import Account
|
|
from eth_account.messages import encode_typed_data
|
|
from x402.mechanisms.evm.eip712 import build_typed_data_for_signing
|
|
from x402.mechanisms.evm.types import DOMAIN_TYPES, ExactEIP3009Authorization
|
|
from x402.mechanisms.evm.utils import get_evm_chain_id
|
|
|
|
accepted = payload_data.get("accepted", {})
|
|
inner = payload_data.get("payload", {})
|
|
auth = inner.get("authorization", {})
|
|
signature = inner.get("signature", "")
|
|
|
|
if not all([auth, signature, accepted]):
|
|
return {"verified": False, "reason": "Missing authorization or signature"}
|
|
|
|
# Build typed data exactly as the SDK client did
|
|
chain_id = get_evm_chain_id(accepted["network"])
|
|
|
|
authorization = ExactEIP3009Authorization(
|
|
from_address=auth["from"],
|
|
to=auth["to"],
|
|
value=auth["value"],
|
|
valid_after=auth["validAfter"],
|
|
valid_before=auth["validBefore"],
|
|
nonce=auth["nonce"],
|
|
)
|
|
|
|
# Get name/version from extra (this is what the SDK used to sign)
|
|
extra = accepted.get("extra", {})
|
|
token_name = extra.get("name", "USD Coin")
|
|
token_version = extra.get("version", "2")
|
|
|
|
domain, types, primary_type, message = build_typed_data_for_signing(
|
|
authorization, chain_id, accepted["asset"], token_name, token_version
|
|
)
|
|
|
|
# Verify time validity
|
|
now = int(time.time())
|
|
valid_before = int(auth["validBefore"])
|
|
valid_after = int(auth["validAfter"])
|
|
if valid_before < now + 6:
|
|
return {"verified": False, "reason": "Authorization expired (validBefore passed)"}
|
|
if valid_after > now:
|
|
return {
|
|
"verified": False,
|
|
"reason": "Authorization not yet valid (validAfter in future)",
|
|
}
|
|
|
|
# Build typed data for eth_account verification
|
|
all_types = {**DOMAIN_TYPES, **types}
|
|
typed_data = {
|
|
"types": {k: [{"name": f["name"], "type": f["type"]} for f in v] for k, v in all_types.items()},
|
|
"primaryType": primary_type,
|
|
"domain": {
|
|
"name": domain.name,
|
|
"version": domain.version,
|
|
"chainId": domain.chain_id,
|
|
"verifyingContract": domain.verifying_contract,
|
|
},
|
|
"message": {k: ("0x" + v.hex() if isinstance(v, bytes) else str(v)) for k, v in message.items()},
|
|
}
|
|
|
|
# Recover signer from signature
|
|
signable = encode_typed_data(full_message=typed_data)
|
|
recovered = Account.recover_message(signable, signature=signature)
|
|
|
|
if recovered.lower() != auth["from"].lower():
|
|
return {
|
|
"verified": False,
|
|
"reason": f"Signature mismatch: recovered {recovered}, expected {auth['from']}",
|
|
}
|
|
|
|
# Verify recipient matches payTo
|
|
if auth["to"].lower() != accepted.get("payTo", "").lower():
|
|
return {"verified": False, "reason": "Recipient address mismatch"}
|
|
|
|
# Verify amount matches
|
|
if str(auth["value"]) != str(accepted.get("amount", "")):
|
|
return {"verified": False, "reason": "Amount mismatch"}
|
|
|
|
return {
|
|
"verified": True,
|
|
"reason": "EIP-712 signature verified locally",
|
|
"payer": auth["from"],
|
|
"tx_hash": None,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Local EIP-712 verification error: {e}")
|
|
return {"verified": False, "reason": f"Local verification error: {e!s}"}
|
|
|
|
async def _verify_via_payai(
|
|
self,
|
|
payload_data: dict,
|
|
requirements_data: dict | None,
|
|
) -> dict[str, Any]:
|
|
"""Fallback: verify via PayAI facilitator HTTP."""
|
|
import aiohttp
|
|
|
|
# Build flattened requirements format PayAI expects
|
|
accepted = payload_data.get("accepted", {})
|
|
flat_req = {
|
|
"x402Version": 2,
|
|
"scheme": accepted.get("scheme", "exact"),
|
|
"network": accepted.get("network", "eip155:8453"),
|
|
"asset": accepted.get("asset", ""),
|
|
"amount": accepted.get("amount", ""),
|
|
"payTo": accepted.get("payTo", ""),
|
|
"maxTimeoutSeconds": accepted.get("maxTimeoutSeconds", 60),
|
|
"extra": accepted.get("extra", {}),
|
|
"resource": {},
|
|
}
|
|
if requirements_data:
|
|
flat_req["resource"] = requirements_data.get("resource", {})
|
|
|
|
body = {
|
|
"x402Version": 2,
|
|
"paymentPayload": payload_data,
|
|
"paymentRequirements": flat_req,
|
|
}
|
|
|
|
try:
|
|
async with aiohttp.ClientSession() as session, session.post(
|
|
"https://facilitator.payai.network/verify",
|
|
json=body,
|
|
headers={"Content-Type": "application/json"},
|
|
timeout=aiohttp.ClientTimeout(total=15),
|
|
) as resp:
|
|
data = await resp.json()
|
|
if data.get("isValid"):
|
|
return {
|
|
"verified": True,
|
|
"reason": "Verified via PayAI",
|
|
"payer": data.get("payer"),
|
|
"tx_hash": None,
|
|
}
|
|
return {
|
|
"verified": False,
|
|
"reason": f"PayAI: {data.get('invalidReason', 'unknown')} - {data.get('invalidMessage', '')}",
|
|
"payer": data.get("payer"),
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"PayAI verification error: {e}")
|
|
return {"verified": False, "reason": f"PayAI connection error: {e!s}"}
|
|
|
|
async def verify_payment(
|
|
self,
|
|
x402_payload: str,
|
|
network_key: str = "base",
|
|
expected_amount: str | None = None,
|
|
expected_tool: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Verify an x402 payment payload with layered verification.
|
|
|
|
Priority:
|
|
1. Local EIP-712 verification (Base/EVM, instant, no external deps)
|
|
2. PayAI facilitator fallback
|
|
"""
|
|
# Step 1: Parse the payment payload
|
|
try:
|
|
payload_data = json.loads(x402_payload)
|
|
except json.JSONDecodeError:
|
|
return {
|
|
"verified": False,
|
|
"reason": "Invalid JSON in x402 payload",
|
|
"tx_hash": None,
|
|
"amount": None,
|
|
"network": network_key,
|
|
"settlement_id": None,
|
|
}
|
|
|
|
# Step 2: Local EIP-712 verification (primary for Base/EVM)
|
|
if network_key.startswith("base"):
|
|
local_result = self._verify_eip712_local(payload_data)
|
|
if local_result["verified"]:
|
|
return {
|
|
"verified": True,
|
|
"reason": local_result["reason"],
|
|
"tx_hash": local_result.get("tx_hash"),
|
|
"amount": expected_amount,
|
|
"network": network_key,
|
|
"settlement_id": None,
|
|
"payer": local_result.get("payer"),
|
|
}
|
|
logger.warning(f"Local verification failed: {local_result['reason']}")
|
|
# Fall through to facilitator
|
|
|
|
# Step 3: Facilitator fallback
|
|
config = FACILITATOR_CONFIGS.get(network_key, FACILITATOR_CONFIGS.get("base"))
|
|
config["url"]
|
|
|
|
# Build requirements from payload data for facilitator
|
|
requirements_data = None
|
|
try:
|
|
accepted = payload_data.get("accepted", {})
|
|
resource = payload_data.get("resource", {})
|
|
requirements_data = {
|
|
"x402Version": payload_data.get("x402Version", 2),
|
|
"resource": resource,
|
|
"accepts": [
|
|
{
|
|
"scheme": accepted.get("scheme", "exact"),
|
|
"network": accepted.get("network", config["network"]),
|
|
"asset": accepted.get("asset", ""),
|
|
"amount": accepted.get("amount", ""),
|
|
"payTo": accepted.get("payTo", ""),
|
|
"maxTimeoutSeconds": accepted.get("maxTimeoutSeconds", 60),
|
|
"extra": accepted.get("extra", {}),
|
|
}
|
|
],
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
return await self._verify_via_payai(payload_data, requirements_data)
|
|
|
|
|
|
# Global verifier instance
|
|
_verifier = PaymentVerifier()
|
|
|
|
|
|
# ── Public Receipt Verification ───────────────────────────────
|
|
|
|
|
|
@router.get("/receipt/{receipt_id}/verify")
|
|
async def verify_receipt_public(receipt_id: str):
|
|
"""
|
|
Public endpoint to verify any x402 receipt.
|
|
Anyone can check if a payment was fulfilled - no auth needed.
|
|
Returns: receipt data + on-chain proof + verification status.
|
|
"""
|
|
try:
|
|
from app.core.redis import get_redis
|
|
|
|
r = await get_redis()
|
|
|
|
receipt_data = await r.get(f"x402:receipt:{receipt_id}")
|
|
if not receipt_data:
|
|
raise HTTPException(status_code=404, detail="Receipt not found")
|
|
|
|
receipt = json.loads(receipt_data)
|
|
|
|
# Compute integrity hash for transparency
|
|
integrity_data = f"{receipt_id}:{receipt.get('amount')}:{receipt.get('status')}:{receipt.get('timestamp')}"
|
|
integrity_hash = hashlib.sha256(integrity_data.encode()).hexdigest()[:16]
|
|
|
|
# Determine verification status
|
|
verification_status = "unverified"
|
|
if receipt.get("status") == "fulfilled":
|
|
verification_status = "verified"
|
|
if receipt.get("payment_tx"):
|
|
verification_status = "on_chain_confirmed"
|
|
elif receipt.get("status") == "refunded":
|
|
verification_status = "refunded"
|
|
|
|
return {
|
|
"receipt_id": receipt_id,
|
|
"status": receipt.get("status"),
|
|
"verification": verification_status,
|
|
"amount": receipt.get("amount"),
|
|
"asset": receipt.get("asset"),
|
|
"network": receipt.get("network"),
|
|
"tool": receipt.get("tool"),
|
|
"customer": receipt.get("customer_address", "")[:8] + "..." if receipt.get("customer_address") else None,
|
|
"timestamp": receipt.get("timestamp"),
|
|
"payment_tx": receipt.get("payment_tx"),
|
|
"delivery_status": receipt.get("delivery_status"),
|
|
"integrity_hash": integrity_hash,
|
|
"chain_of_custody": {
|
|
"stored": receipt.get("updated_at") is not None,
|
|
"fulfilled": receipt.get("status") == "fulfilled",
|
|
"on_chain": receipt.get("payment_tx") is not None and receipt.get("payment_tx") != "",
|
|
},
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
|
|
|
|
# ── Public Payment Ledger ─────────────────────────────────────
|
|
|
|
|
|
@router.get("/ledger")
|
|
async def payment_ledger(
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
tool: str | None = None,
|
|
network: str | None = None,
|
|
status: str | None = None,
|
|
):
|
|
"""
|
|
Public anonymized payment ledger for transparency.
|
|
Shows: tool used, amount, network, timestamp, status.
|
|
No wallet addresses, no PII.
|
|
"""
|
|
try:
|
|
from app.core.redis import get_redis
|
|
|
|
r = await get_redis()
|
|
|
|
# Get recent receipt IDs
|
|
all_ids = await r.zrevrange("x402:receipts_by_time", offset, offset + limit - 1)
|
|
|
|
ledger = []
|
|
for rid in all_ids:
|
|
rid_str = rid.decode() if isinstance(rid, bytes) else rid
|
|
data = await r.get(f"x402:receipt:{rid_str}")
|
|
if not data:
|
|
continue
|
|
|
|
receipt = json.loads(data)
|
|
|
|
# Filter by tool/network/status if specified
|
|
if tool and receipt.get("tool") != tool:
|
|
continue
|
|
if network and receipt.get("network") != network:
|
|
continue
|
|
if status and receipt.get("status") != status:
|
|
continue
|
|
|
|
# Anonymize customer
|
|
customer = receipt.get("customer_address", "")
|
|
anon_customer = customer[:6] + "..." + customer[-4:] if len(customer) > 10 else None
|
|
|
|
ledger.append(
|
|
{
|
|
"receipt_id": rid_str,
|
|
"tool": receipt.get("tool"),
|
|
"amount": receipt.get("amount"),
|
|
"asset": receipt.get("asset"),
|
|
"network": receipt.get("network"),
|
|
"status": receipt.get("status"),
|
|
"delivery": receipt.get("delivery_status"),
|
|
"timestamp": receipt.get("timestamp"),
|
|
"customer": anon_customer,
|
|
}
|
|
)
|
|
|
|
# Get total count
|
|
total = await r.zcard("x402:receipts_by_time")
|
|
|
|
return {
|
|
"ledger": ledger,
|
|
"count": len(ledger),
|
|
"total": total,
|
|
"offset": offset,
|
|
"limit": limit,
|
|
}
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
|
|
|
|
# ── Transparency Dashboard ────────────────────────────────────
|
|
|
|
|
|
@router.get("/transparency")
|
|
async def transparency_dashboard(hours: int = 24):
|
|
"""
|
|
Public transparency dashboard showing system trust metrics.
|
|
No sensitive data - all aggregated and anonymized.
|
|
"""
|
|
try:
|
|
from app.core.redis import get_redis
|
|
|
|
r = await get_redis()
|
|
|
|
cutoff = time.time() - (hours * 3600)
|
|
recent_ids = await r.zrangebyscore("x402:receipts_by_time", cutoff, "+inf")
|
|
|
|
total = fulfilled = refunded = failed = 0
|
|
total_revenue = 0.0
|
|
tool_counts: dict[str, int] = {}
|
|
network_counts: dict[str, int] = {}
|
|
# hourly array intentionally skipped - data is fetched on-demand
|
|
for rid in recent_ids:
|
|
rid_str = rid.decode() if isinstance(rid, bytes) else rid
|
|
data = await r.get(f"x402:receipt:{rid_str}")
|
|
if not data:
|
|
continue
|
|
|
|
receipt = json.loads(data)
|
|
total += 1
|
|
|
|
status = receipt.get("status", "unknown")
|
|
if status == "fulfilled":
|
|
fulfilled += 1
|
|
elif status == "refunded":
|
|
refunded += 1
|
|
elif status in ("failed", "error"):
|
|
failed += 1
|
|
|
|
# Revenue tracking (amounts stored in atomic units, convert to USDC)
|
|
try:
|
|
amount = float(receipt.get("amount", 0))
|
|
total_revenue += amount / 1e6
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Tool breakdown
|
|
tool_name = receipt.get("tool", "unknown")
|
|
tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1
|
|
|
|
# Network breakdown
|
|
net = receipt.get("network", "unknown")
|
|
network_counts[net] = network_counts.get(net, 0) + 1
|
|
|
|
# Get trial stats using SCAN (KEYS is disabled)
|
|
total_trials_used = 0
|
|
cursor = 0
|
|
while True:
|
|
cursor, keys = await r.scan(cursor, match="x402:trial:*", count=100)
|
|
for k in keys:
|
|
val = await r.get(k)
|
|
if val:
|
|
with contextlib.suppress(ValueError, TypeError):
|
|
total_trials_used += int(val)
|
|
if cursor == 0:
|
|
break
|
|
|
|
# Get ban stats
|
|
ip_bans = wallet_bans = 0
|
|
async for key in r.scan_iter(match="x402:ban:ip:*"):
|
|
k = key.decode() if isinstance(key, bytes) else key
|
|
if ":reason:" not in k:
|
|
ip_bans += 1
|
|
async for key in r.scan_iter(match="x402:ban:wallet:*"): # noqa: B007
|
|
wallet_bans += 1
|
|
|
|
fulfillment_rate = fulfilled / max(1, total)
|
|
refund_rate = refunded / max(1, total)
|
|
|
|
return {
|
|
"period_hours": hours,
|
|
"payments": {
|
|
"total": total,
|
|
"fulfilled": fulfilled,
|
|
"refunded": refunded,
|
|
"failed": failed,
|
|
"fulfillment_rate": round(fulfillment_rate, 4),
|
|
"refund_rate": round(refund_rate, 4),
|
|
"total_revenue_usdc": round(total_revenue, 4),
|
|
},
|
|
"tools": dict(sorted(tool_counts.items(), key=lambda x: -x[1])[:10]),
|
|
"networks": network_counts,
|
|
"trials": {
|
|
"total_used": total_trials_used,
|
|
},
|
|
"security": {
|
|
"ip_bans_active": ip_bans,
|
|
"wallet_bans_active": wallet_bans,
|
|
"ban_duration_hours": 24,
|
|
},
|
|
"trust_score": round(
|
|
(fulfillment_rate * 0.6 + (1 - refund_rate) * 0.3 + (1 - failed / max(1, total)) * 0.1) * 100,
|
|
1,
|
|
),
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
}
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
|
|
|
|
# ── Payment Verification Endpoint (for gateway use) ────────────
|
|
|
|
|
|
@router.post("/verify")
|
|
async def verify_payment_endpoint(
|
|
request: Request,
|
|
):
|
|
"""
|
|
Verify an x402 payment payload.
|
|
Used by the gateway worker to verify payments before executing tools.
|
|
Exempt from rate limiting - gateway workers call this for every paid request.
|
|
|
|
POST body: {
|
|
"payload": "<x402 payment payload JSON string>",
|
|
"network": "base" | "solana",
|
|
"tool": "audit" | "rugshield" | ...,
|
|
"amount": "$0.02" | ...,
|
|
"amount_atomic": "20000" | ...
|
|
}
|
|
|
|
Returns: {
|
|
"verified": true/false,
|
|
"reason": "...",
|
|
"tx_hash": "...",
|
|
"settlement_id": "...",
|
|
}
|
|
"""
|
|
try:
|
|
body = await request.json()
|
|
x402_payload = body.get("payload", "")
|
|
network = body.get("network", "base")
|
|
tool = body.get("tool")
|
|
amount = body.get("amount")
|
|
|
|
if not x402_payload:
|
|
raise HTTPException(status_code=400, detail="Missing x402 payload")
|
|
|
|
result = await _verifier.verify_payment(
|
|
x402_payload,
|
|
network_key=network,
|
|
expected_amount=amount,
|
|
expected_tool=tool,
|
|
)
|
|
|
|
# Store verification result for audit trail
|
|
if result["verified"]:
|
|
try:
|
|
from app.core.redis import get_redis
|
|
|
|
r = await get_redis()
|
|
verify_key = f"x402:verify:{result.get('settlement_id') or str(time.time())}"
|
|
await r.set(
|
|
verify_key,
|
|
json.dumps(
|
|
{
|
|
"verified_at": datetime.now(UTC).isoformat(),
|
|
"network": network,
|
|
"tool": tool,
|
|
"amount": amount,
|
|
"tx_hash": result.get("tx_hash"),
|
|
"result": result["reason"],
|
|
}
|
|
),
|
|
ex=7 * 86400,
|
|
) # Keep for 7 days
|
|
except Exception as e:
|
|
logger.debug(f"Audit store failed: {e}")
|
|
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Payment verification error: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
|
|
|
|
# ── Receipt Storage Endpoint (called by x402 Workers after payment) ────
|
|
|
|
|
|
@router.post("/receipt")
|
|
async def store_receipt(request: Request):
|
|
"""
|
|
Store a payment receipt from an x402 Worker after successful payment verification.
|
|
|
|
AUTH: Requires X-API-Key header matching ADMIN_API_KEY env var.
|
|
Called by x402-base and x402-sol Workers after verifyPayment succeeds.
|
|
Stores receipt in Redis for revenue tracking, audit trail, and transparency dashboard.
|
|
|
|
POST body: {
|
|
"receipt_id": "rmi-<timestamp>-<rand>",
|
|
"payment_tx": "0x...|<sol-sig>",
|
|
"amount": "$0.01"|0.01|10000,
|
|
"asset": "USDC",
|
|
"network": "base"|"solana"|<chain>,
|
|
"customer_address": "0x...|<sol-addr>",
|
|
"scan_request": {},
|
|
"status": "fulfilled",
|
|
"tool": "urlcheck"|"audit"|...,
|
|
"delivery_status": "fulfilled",
|
|
"fallback_chain": []
|
|
}
|
|
"""
|
|
# Auth: require admin API key
|
|
admin_key = os.getenv("ADMIN_API_KEY", os.getenv("WP_ADMIN_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"})
|
|
|
|
try:
|
|
body = await request.json()
|
|
receipt_id = body.get("receipt_id", f"rmi-{int(time.time())}-unknown")
|
|
payment_tx = body.get("payment_tx", "")
|
|
amount = body.get("amount", "")
|
|
asset = body.get("asset", "USDC")
|
|
network = body.get("network", "unknown")
|
|
customer_address = body.get("customer_address", "")
|
|
tool = body.get("tool", "unknown")
|
|
status = body.get("status", "fulfilled")
|
|
delivery_status = body.get("delivery_status", "fulfilled")
|
|
fallback_chain = body.get("fallback_chain", [])
|
|
scan_request = body.get("scan_request", {})
|
|
|
|
# Normalize amount
|
|
if isinstance(amount, str):
|
|
amount_val = amount.replace("$", "")
|
|
try:
|
|
amount_float = float(amount_val)
|
|
except (ValueError, TypeError):
|
|
amount_float = 0.0
|
|
elif isinstance(amount, (int, float)):
|
|
amount_float = float(amount)
|
|
else:
|
|
amount_float = 0.0
|
|
|
|
# Store in Redis (sync - app.auth.get_redis returns a sync client)
|
|
from app.core.redis import get_redis as _get_redis
|
|
|
|
r = _get_redis()
|
|
|
|
if r:
|
|
receipt_data = {
|
|
"receipt_id": receipt_id,
|
|
"payment_tx": payment_tx,
|
|
"amount": str(amount_float) if amount_float else str(amount),
|
|
"asset": asset,
|
|
"network": network,
|
|
"customer_address": customer_address,
|
|
"tool": tool,
|
|
"status": status,
|
|
"delivery_status": delivery_status,
|
|
"timestamp": str(time.time()),
|
|
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
"fallback_chain": fallback_chain,
|
|
"scan_request": scan_request,
|
|
"amount_usd": amount_float,
|
|
}
|
|
|
|
# Store receipt with 30-day TTL
|
|
r.set(f"x402:receipt:{receipt_id}", json.dumps(receipt_data), ex=30 * 86400)
|
|
|
|
# Also store by transaction hash for lookup
|
|
if payment_tx and payment_tx != "local-verified":
|
|
r.set(
|
|
f"x402:spent_tx:{payment_tx}",
|
|
json.dumps(
|
|
{
|
|
"chain": network,
|
|
"payer": customer_address,
|
|
"amount": str(amount_float),
|
|
"tool": tool,
|
|
"timestamp": str(time.time()),
|
|
"receipt_id": receipt_id,
|
|
}
|
|
),
|
|
ex=30 * 86400,
|
|
)
|
|
|
|
# Add to time-ordered index for ledger queries
|
|
r.zadd("x402:receipts_by_time", {receipt_id: time.time()})
|
|
|
|
# Increment revenue counter
|
|
today = time.strftime("%Y-%m-%d")
|
|
r.incrbyfloat(f"x402:revenue:daily:{today}", amount_float or 0)
|
|
r.incrbyfloat("x402:revenue:total", amount_float or 0)
|
|
r.incrby(f"x402:calls:daily:{today}", 1)
|
|
r.incrby("x402:calls:total", 1)
|
|
|
|
# Tool-level stats
|
|
r.incrby(f"x402:tool_calls:{tool}", 1)
|
|
r.incrbyfloat(f"x402:tool_revenue:{tool}", amount_float or 0)
|
|
|
|
logger.info(f"Receipt stored: {receipt_id} tx={payment_tx[:16]}... tool={tool} amount=${amount_float}")
|
|
|
|
return {
|
|
"ok": True,
|
|
"receipt_id": receipt_id,
|
|
"stored": True,
|
|
"message": "Receipt stored successfully",
|
|
}
|
|
else:
|
|
# Redis unavailable - still return success so Worker doesn't retry endlessly
|
|
logger.warning(f"Redis unavailable, receipt not stored: {receipt_id}")
|
|
return {
|
|
"ok": True,
|
|
"receipt_id": receipt_id,
|
|
"stored": False,
|
|
"message": "Receipt received but Redis unavailable",
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Receipt store error: {e}")
|
|
# Return 200 so Worker doesn't retry - receipt storage is best-effort
|
|
return {
|
|
"ok": True,
|
|
"stored": False,
|
|
"error": str(e)[:200],
|
|
"message": "Receipt received but storage failed",
|
|
}
|
|
|
|
|
|
# ── Fallback System Status ────────────────────────────────────
|
|
|
|
|
|
@router.get("/fallback/status")
|
|
async def fallback_status():
|
|
"""
|
|
Check fallback system health, cache stats, and fallback success rate.
|
|
Shows which fallback layers are working and which are failing.
|
|
"""
|
|
try:
|
|
from app.core.redis import get_redis
|
|
|
|
r = await get_redis()
|
|
|
|
# Count cache entries
|
|
cache_count = 0
|
|
async for _key in r.scan_iter(match="x402:cache:*", count=100):
|
|
cache_count += 1
|
|
|
|
# Get fallback usage stats
|
|
fallback_uses = 0
|
|
async for _key in r.scan_iter(match="x402:fallback:*", count=100):
|
|
fallback_uses += 1
|
|
|
|
return {
|
|
"fallback_system": "active",
|
|
"cache_entries": cache_count,
|
|
"fallback_uses": fallback_uses,
|
|
"layers_available": [
|
|
"cache",
|
|
"geckoterminal",
|
|
"dexlab",
|
|
"step_finance",
|
|
"moonrank",
|
|
"web_scrape",
|
|
"dexscreener_web",
|
|
"raydium",
|
|
"orca",
|
|
"jito",
|
|
"twitter",
|
|
"reddit",
|
|
"news",
|
|
"web_search",
|
|
"bigquery",
|
|
],
|
|
"priority_order": [
|
|
"1. Primary APIs (DexScreener, RPCs, etc.)",
|
|
"2. Secondary APIs (Birdeye, Jupiter, Coingecko)",
|
|
"3. Tertiary APIs (DeFiLlama, GeckoTerminal, DexLab)",
|
|
"4. Web scraping (Solscan, Etherscan, Basescan HTML)",
|
|
"5. Browser automation (JS-heavy sites)",
|
|
"6. AI web search (DuckDuckGo, Google)",
|
|
"7. Public data archives (BigQuery public datasets)",
|
|
"8. Community APIs (Jito, Orca, Raydium)",
|
|
"9. RSS/feeds (crypto news, launch trackers)",
|
|
"10. Social media (Twitter/X, Reddit public)",
|
|
"11. Cache layer (previous successful results)",
|
|
],
|
|
"refund_policy": "Refund only if ALL fallback layers fail",
|
|
}
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|