rmi-backend/app/domains/billing/facilitators/payai.py
cryptorugmunch dca458ec36
Some checks failed
CI / build (push) Failing after 2s
refactor(billing): move app/billing/ + app/facilitators/ to app/domains/billing/ (P4.1)
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)
2026-07-06 22:37:53 +02:00

220 lines
8.3 KiB
Python

"""
PayAI x402 Facilitator
=======================
Hosted facilitator that verifies x402 payments via the PayAI API.
Supports Base (mainnet + Sepolia) and Solana (mainnet + devnet)
with USDC token - settlement is deferred (batched by PayAI).
Priority: 20 (second after Coinbase CDP for Base; primary for Solana)
"""
from __future__ import annotations
import logging
from typing import Any
import aiohttp
from app.domains.billing.facilitators.base import (
Facilitator,
FacilitatorType,
NetworkSupport,
SettlementType,
VerificationResult,
)
from app.domains.billing.facilitators.config import get_config
logger = logging.getLogger("facilitator.payai")
# ── Verify URL ──────────────────────────────────────────────────────
PAYAI_VERIFY_URL = "https://facilitator.payai.network/verify"
class PayAIFacilitator(Facilitator):
"""PayAI hosted payment facilitator.
Routes Base and Solana USDC payments through PayAI's verification API.
Settlements are batched by PayAI (DEFERRED), so no explicit settle()
call is needed - the default base-class no-op handles this.
"""
# ── Identity ───────────────────────────────────────────────────
@property
def name(self) -> str:
return "payai"
@property
def facilitator_type(self) -> FacilitatorType:
return FacilitatorType.HOSTED
@property
def settlement_type(self) -> SettlementType:
return SettlementType.DEFERRED
@property
def supported_networks(self) -> NetworkSupport:
return NetworkSupport(
chains=["base", "solana"],
chain_ids=[8453, 84532],
networks=[
"eip155:8453",
"eip155:84532",
"solana:mainnet",
"solana:devnet",
],
tokens=["USDC"],
token_addresses={
# USDC on Base mainnet
"USDC:8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
# USDC on Base Sepolia
"USDC:84532": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
# USDC on Solana mainnet
"USDC:solana": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
},
)
@property
def priority(self) -> int:
return 20
@property
def verify_url(self) -> str | None:
return self._verify_url
# ── Constructor ────────────────────────────────────────────────
def __init__(self) -> None:
config = get_config()
self._verify_url = config.payai_verify_url or PAYAI_VERIFY_URL
# ── Health Check ───────────────────────────────────────────────
async def health(self) -> bool:
"""Ping the PayAI verify endpoint to check reachability.
Sends a lightweight health-check payload. Any non-5xx response
means the API is reachable.
"""
try:
async with (
aiohttp.ClientSession() as session,
session.post(
self._verify_url,
json={"health_check": True, "x402Version": 2},
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=10),
) as resp,
):
if resp.status < 500:
logger.debug("PayAI health check: HTTP %d", resp.status)
return True
logger.warning("PayAI health check failed: HTTP %d", resp.status)
return False
except TimeoutError:
logger.warning("PayAI health check timed out")
return False
except aiohttp.ClientError as e:
logger.warning("PayAI health check connection error: %s", e)
return False
except Exception:
logger.exception("PayAI health check unexpected error")
return False
# ── Verification ───────────────────────────────────────────────
async def verify(
self,
payload: dict[str, Any],
requirements: dict[str, Any] | None = None,
) -> VerificationResult:
"""Verify an x402 payment via the PayAI API.
Builds the same body format PayAI expects (mirroring the
original _verify_via_payai logic from x402_middleware.py)
and POSTs it to https://facilitator.payai.network/verify.
Args:
payload: x402 payment payload (parsed JSON) containing
an "accepted" block with scheme, network, asset,
amount, payTo, maxTimeoutSeconds, and extra.
requirements: Optional payment requirements dict whose
"resource" key is forwarded to PayAI.
Returns:
VerificationResult with verification status and details.
"""
try:
# Build flattened requirements format PayAI expects
accepted = payload.get("accepted", {})
flat_req: dict[str, Any] = {
"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 and requirements.get("resource"):
flat_req["resource"] = requirements["resource"]
body = {
"x402Version": 2,
"paymentPayload": payload,
"paymentRequirements": flat_req,
}
config = get_config()
timeout = aiohttp.ClientTimeout(total=config.facilitator_verify_timeout)
async with (
aiohttp.ClientSession() as session,
session.post(
self._verify_url,
json=body,
headers={"Content-Type": "application/json"},
timeout=timeout,
) as resp,
):
data = await resp.json()
if data.get("isValid"):
logger.info(
"PayAI verification succeeded: payer=%s",
data.get("payer", "N/A"),
)
return self._format_success(
payer=data.get("payer"),
chain=data.get("chain"),
token=data.get("token", "USDC"),
amount=data.get("amount"),
amount_usd=data.get("amount_usd"),
tx_hash=data.get("tx_hash"),
settlement_id=data.get("settlement_id"),
block_number=data.get("block_number"),
confirmations=data.get("confirmations"),
raw_response=data,
)
reason = f"PayAI: {data.get('invalidReason', 'unknown')} - {data.get('invalidMessage', '')}"
logger.warning("PayAI verification failed: %s", reason)
return self._format_error(
reason,
payer=data.get("payer"),
raw_response=data,
)
except TimeoutError:
logger.error("PayAI verify timed out")
return self._format_error("PayAI API verification timed out")
except aiohttp.ClientError as e:
logger.error("PayAI HTTP error during verify: %s", e)
return self._format_error(f"PayAI connection error: {e}")
except Exception:
logger.exception("PayAI verify unexpected error")
return self._format_error("Unexpected PayAI verify error")