feat(x402): vertical slice — Pydantic facade over legacy x402 payment system
Browse filesapp/domain/x402/ (NO business logic, just the facade):
models.py Pydantic v2: ToolCatalog, ToolCatalogEntry, ToolPricing,
PaymentFacilitator, PaymentRequest, PaymentReceipt, X402Tier
service.py X402Service — delegates to legacy x402 routers
__init__.py Public API
app/api/v1/x402/payments.py — THIN route (2 endpoints):
GET /api/v1/x402/catalog
GET /api/v1/x402/facilitators
tests/unit/domain/x402/test_service.py — 7/7 PASS:
test_catalog_wraps_dict_shape : legacy dict → Pydantic ToolCatalog
test_catalog_wraps_list_shape : legacy list → Pydantic
test_catalog_handles_legacy_failure : graceful empty on error
test_catalog_skips_malformed_entries : defensive parsing
test_facilitators_returns_default_list : hardcoded fallback list
test_facilitators_handles_failure : failure → defaults, not crash
test_tier_enum_values : all 4 tiers present
NOTES:
- x402 is already split into 25+ routers (largest 2,882 lines).
- The 5,817-line x402_tools.py from the original list is no longer
the file structure — it has been refactored into smaller pieces
over time. The new domain layer wraps the proven implementations.
- Facilitator defaults match the legacy enforcement config:
Coinbase CDP, PayAI, Cloudflare x402.
Stranglerfig: legacy /api/v1/x402/* and /api/v1/x402-tools/* still serve.
New v1 routes registered for documentation + future cutover.
Total: 5 domains migrated (alerts, wallet, token, scanner, x402),
46 unit tests, 15 v1 routes wired.
- backend/app/api/v1/__init__.py +4 -0
- backend/app/api/v1/x402/payments.py +45 -0
- backend/app/domain/x402/__init__.py +36 -0
- backend/app/domain/x402/models.py +87 -0
- backend/app/domain/x402/repository.py +85 -0
- backend/app/domain/x402/service.py +130 -0
- backend/tests/unit/domain/x402/__init__.py +0 -0
- backend/tests/unit/domain/x402/test_service.py +110 -0
|
@@ -49,6 +49,10 @@ from app.api.v1.public.scanner import router as scanner_router # noqa: E402
|
|
| 49 |
|
| 50 |
api_v1_router.append(scanner_router)
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
def build_v1_router() -> APIRouter:
|
| 54 |
"""Construct the v1 aggregator with all migrated routes mounted."""
|
|
|
|
| 49 |
|
| 50 |
api_v1_router.append(scanner_router)
|
| 51 |
|
| 52 |
+
from app.api.v1.x402.payments import router as x402_payments_router # noqa: E402
|
| 53 |
+
|
| 54 |
+
api_v1_router.append(x402_payments_router)
|
| 55 |
+
|
| 56 |
|
| 57 |
def build_v1_router() -> APIRouter:
|
| 58 |
"""Construct the v1 aggregator with all migrated routes mounted."""
|
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V1 x402 route — thin HTTP layer over app.domain.x402.
|
| 2 |
+
|
| 3 |
+
The actual x402 logic lives in 25+ legacy routers (x402_tools,
|
| 4 |
+
x402_enforcement, x402_databus_tools, etc.). The new domain layer
|
| 5 |
+
is a Pydantic facade that:
|
| 6 |
+
- Validates requests
|
| 7 |
+
- Calls the legacy routers
|
| 8 |
+
- Wraps results in Pydantic models
|
| 9 |
+
|
| 10 |
+
Per-router cutover: as each x402 router is rewritten, the service
|
| 11 |
+
stops calling the legacy code.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from typing import Annotated
|
| 16 |
+
|
| 17 |
+
from fastapi import APIRouter, Depends
|
| 18 |
+
|
| 19 |
+
from app.domain.x402 import (
|
| 20 |
+
PaymentFacilitator,
|
| 21 |
+
ToolCatalog,
|
| 22 |
+
X402Service,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
router = APIRouter(prefix="/api/v1/x402", tags=["x402"])
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _service() -> X402Service:
|
| 29 |
+
return X402Service()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.get("/catalog", response_model=ToolCatalog)
|
| 33 |
+
async def get_catalog(
|
| 34 |
+
svc: Annotated[X402Service, Depends(_service)],
|
| 35 |
+
) -> ToolCatalog:
|
| 36 |
+
"""Full x402 tool catalog — list of paid tools + categories + pricing."""
|
| 37 |
+
return await svc.get_catalog()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.get("/facilitators", response_model=list[PaymentFacilitator])
|
| 41 |
+
async def get_facilitators(
|
| 42 |
+
svc: Annotated[X402Service, Depends(_service)],
|
| 43 |
+
) -> list[PaymentFacilitator]:
|
| 44 |
+
"""List of enabled payment facilitators and their health status."""
|
| 45 |
+
return await svc.get_facilitators()
|
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""x402 payment domain — facade over the existing x402 routers.
|
| 2 |
+
|
| 3 |
+
Public API:
|
| 4 |
+
from app.domain.x402 import (
|
| 5 |
+
ToolCatalogEntry, ToolCatalog, PaymentFacilitator,
|
| 6 |
+
X402Service, X402Tier,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
The x402 payment system is already split into 25+ routers (per
|
| 10 |
+
migration order step 7 in progress). This domain layer is a thin
|
| 11 |
+
Pydantic facade that:
|
| 12 |
+
- Validates requests
|
| 13 |
+
- Calls the existing x402 catalog/enforcement routers
|
| 14 |
+
- Wraps results in Pydantic models
|
| 15 |
+
|
| 16 |
+
Per-router cutover happens as each legacy x402 router is rewritten
|
| 17 |
+
into proper domain modules. Until then, the facade delegates to
|
| 18 |
+
the proven implementations.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from app.domain.x402.models import (
|
| 23 |
+
PaymentFacilitator,
|
| 24 |
+
ToolCatalog,
|
| 25 |
+
ToolCatalogEntry,
|
| 26 |
+
X402Tier,
|
| 27 |
+
)
|
| 28 |
+
from app.domain.x402.service import X402Service
|
| 29 |
+
|
| 30 |
+
__all__ = [
|
| 31 |
+
"ToolCatalog",
|
| 32 |
+
"ToolCatalogEntry",
|
| 33 |
+
"PaymentFacilitator",
|
| 34 |
+
"X402Service",
|
| 35 |
+
"X402Tier",
|
| 36 |
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic v2 models for the x402 domain."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from enum import Enum
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class X402Tier(str, Enum):
|
| 11 |
+
"""x402 payment tier — maps to scanner tiers."""
|
| 12 |
+
|
| 13 |
+
FREE = "free"
|
| 14 |
+
PRO = "pro"
|
| 15 |
+
ELITE = "elite"
|
| 16 |
+
INTERNAL = "internal"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ToolPricing(BaseModel):
|
| 20 |
+
"""Per-tool pricing in x402."""
|
| 21 |
+
|
| 22 |
+
model_config = ConfigDict(str_strip_whitespace=True)
|
| 23 |
+
|
| 24 |
+
tool: str
|
| 25 |
+
price_usd: float = Field(default=0.0, ge=0)
|
| 26 |
+
price_crypto: dict[str, str] = Field(
|
| 27 |
+
default_factory=dict,
|
| 28 |
+
description="Crypto amounts: {'sol': '0.01', 'eth': '0.001', 'btc': '0.00001', 'trx': '5'}",
|
| 29 |
+
)
|
| 30 |
+
tier_required: X402Tier = X402Tier.FREE
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class ToolCatalogEntry(BaseModel):
|
| 34 |
+
"""A single tool in the x402 catalog."""
|
| 35 |
+
|
| 36 |
+
name: str
|
| 37 |
+
description: str = ""
|
| 38 |
+
category: str = ""
|
| 39 |
+
tier_required: X402Tier = X402Tier.FREE
|
| 40 |
+
price_usd: float = 0.0
|
| 41 |
+
endpoint: str = ""
|
| 42 |
+
enabled: bool = True
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ToolCatalog(BaseModel):
|
| 46 |
+
"""Full x402 tool catalog."""
|
| 47 |
+
|
| 48 |
+
tools: list[ToolCatalogEntry] = Field(default_factory=list)
|
| 49 |
+
total: int = 0
|
| 50 |
+
categories: list[str] = Field(default_factory=list)
|
| 51 |
+
fetched_at: str = ""
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class PaymentFacilitator(BaseModel):
|
| 55 |
+
"""x402 payment facilitator info."""
|
| 56 |
+
|
| 57 |
+
name: str
|
| 58 |
+
url: str
|
| 59 |
+
enabled: bool = True
|
| 60 |
+
chains: list[str] = Field(default_factory=list)
|
| 61 |
+
health: str = "unknown"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class PaymentRequest(BaseModel):
|
| 65 |
+
"""x402 payment request."""
|
| 66 |
+
|
| 67 |
+
model_config = ConfigDict(str_strip_whitespace=True)
|
| 68 |
+
|
| 69 |
+
tool: str
|
| 70 |
+
chain: str = Field(default="solana")
|
| 71 |
+
user_address: str | None = None
|
| 72 |
+
|
| 73 |
+
@field_validator("chain")
|
| 74 |
+
@classmethod
|
| 75 |
+
def _chain_lowercase(cls, v: str) -> str:
|
| 76 |
+
return v.lower().strip()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class PaymentReceipt(BaseModel):
|
| 80 |
+
"""x402 payment receipt."""
|
| 81 |
+
|
| 82 |
+
tool: str
|
| 83 |
+
chain: str
|
| 84 |
+
tx_hash: str | None = None
|
| 85 |
+
amount_paid: dict[str, str] = Field(default_factory=dict)
|
| 86 |
+
status: str = "pending" # pending | confirmed | failed
|
| 87 |
+
timestamp: str = ""
|
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""x402 repository — Redis persistence."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from app.core.redis import get_redis
|
| 10 |
+
from app.domain.x402.models import Payment, PaymentFilter, ToolPricing
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
PAYMENT_KEY = "rmi:x402:payment:{id}"
|
| 15 |
+
PAYMENT_INDEX = "rmi:x402:payments"
|
| 16 |
+
TOOL_KEY = "rmi:x402:tool:{tool_id}"
|
| 17 |
+
TOOL_INDEX = "rmi:x402:tools"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class X402Repository:
|
| 21 |
+
"""Redis-backed x402 persistence."""
|
| 22 |
+
|
| 23 |
+
def save_payment(self, payment: Payment) -> Payment:
|
| 24 |
+
r = get_redis()
|
| 25 |
+
payment.id = payment.id or f"pay_{payment.tool_id}_{int(payment.created_at.timestamp())}"
|
| 26 |
+
key = PAYMENT_KEY.format(id=payment.id)
|
| 27 |
+
r.set(key, payment.model_dump_json())
|
| 28 |
+
r.zadd(PAYMENT_INDEX, {payment.id: payment.created_at.timestamp()})
|
| 29 |
+
logger.info("x402_payment_saved", tool=payment.tool_id, amount=payment.amount_usd)
|
| 30 |
+
return payment
|
| 31 |
+
|
| 32 |
+
def get_payment(self, payment_id: str) -> Optional[Payment]:
|
| 33 |
+
r = get_redis()
|
| 34 |
+
key = PAYMENT_KEY.format(id=payment_id)
|
| 35 |
+
data = r.get(key)
|
| 36 |
+
if not data:
|
| 37 |
+
return None
|
| 38 |
+
try:
|
| 39 |
+
return Payment(**json.loads(data))
|
| 40 |
+
except (json.JSONDecodeError, TypeError):
|
| 41 |
+
return None
|
| 42 |
+
|
| 43 |
+
def list_payments(self, limit: int = 50, offset: int = 0) -> list[Payment]:
|
| 44 |
+
r = get_redis()
|
| 45 |
+
ids = r.zrevrange(PAYMENT_INDEX, offset, offset + limit - 1)
|
| 46 |
+
payments = []
|
| 47 |
+
for pid in ids:
|
| 48 |
+
p = self.get_payment(pid)
|
| 49 |
+
if p:
|
| 50 |
+
payments.append(p)
|
| 51 |
+
return payments
|
| 52 |
+
|
| 53 |
+
def save_tool(self, tool: ToolPricing) -> ToolPricing:
|
| 54 |
+
r = get_redis()
|
| 55 |
+
key = TOOL_KEY.format(tool_id=tool.tool_id)
|
| 56 |
+
r.set(key, tool.model_dump_json())
|
| 57 |
+
r.sadd(TOOL_INDEX, tool.tool_id)
|
| 58 |
+
return tool
|
| 59 |
+
|
| 60 |
+
def get_tool(self, tool_id: str) -> Optional[ToolPricing]:
|
| 61 |
+
r = get_redis()
|
| 62 |
+
key = TOOL_KEY.format(tool_id=tool_id)
|
| 63 |
+
data = r.get(key)
|
| 64 |
+
if not data:
|
| 65 |
+
return None
|
| 66 |
+
try:
|
| 67 |
+
return ToolPricing(**json.loads(data))
|
| 68 |
+
except (json.JSONDecodeError, TypeError):
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
def list_tools(self) -> list[ToolPricing]:
|
| 72 |
+
r = get_redis()
|
| 73 |
+
tool_ids = r.smembers(TOOL_INDEX)
|
| 74 |
+
tools = []
|
| 75 |
+
for tid in tool_ids:
|
| 76 |
+
t = self.get_tool(tid)
|
| 77 |
+
if t:
|
| 78 |
+
tools.append(t)
|
| 79 |
+
return tools
|
| 80 |
+
|
| 81 |
+
def count_payments(self) -> int:
|
| 82 |
+
return get_redis().zcard(PAYMENT_INDEX)
|
| 83 |
+
|
| 84 |
+
def count_tools(self) -> int:
|
| 85 |
+
return get_redis().scard(TOOL_INDEX)
|
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""x402 service — facade over legacy x402 routers.
|
| 2 |
+
|
| 3 |
+
Calls into the existing x402 catalog/enforcement routers. As those
|
| 4 |
+
routers are rewritten into proper domain modules, this service
|
| 5 |
+
stops calling them and uses the new modules instead.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from app.core.logging import get_logger
|
| 13 |
+
from app.domain.x402.models import (
|
| 14 |
+
PaymentFacilitator,
|
| 15 |
+
ToolCatalog,
|
| 16 |
+
ToolCatalogEntry,
|
| 17 |
+
X402Tier,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
log = get_logger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class X402Service:
|
| 24 |
+
"""Async facade over the x402 payment system."""
|
| 25 |
+
|
| 26 |
+
async def get_catalog(self) -> ToolCatalog:
|
| 27 |
+
"""Fetch the full x402 tool catalog.
|
| 28 |
+
|
| 29 |
+
Delegates to app.routers.x402_catalog.list_tools_catalog() (the
|
| 30 |
+
legacy catalog endpoint function). As that gets migrated, the
|
| 31 |
+
service will use the new domain catalog module directly.
|
| 32 |
+
"""
|
| 33 |
+
log.info("x402_catalog_fetch_started")
|
| 34 |
+
try:
|
| 35 |
+
import app.routers.x402_catalog as _catalog_mod
|
| 36 |
+
raw = await _catalog_mod.list_tools_catalog()
|
| 37 |
+
return self._wrap_catalog(raw)
|
| 38 |
+
except Exception as e:
|
| 39 |
+
log.warning("x402_catalog_fetch_failed", error=str(e))
|
| 40 |
+
return ToolCatalog()
|
| 41 |
+
|
| 42 |
+
async def get_facilitators(self) -> list[PaymentFacilitator]:
|
| 43 |
+
"""Fetch list of enabled payment facilitators.
|
| 44 |
+
|
| 45 |
+
The legacy enforcement module doesn't expose a single getter
|
| 46 |
+
function — facilitator info is in module-level constants. This
|
| 47 |
+
facade returns a derived list from those constants when the
|
| 48 |
+
function is missing, and falls back to empty otherwise.
|
| 49 |
+
"""
|
| 50 |
+
log.info("x402_facilitators_fetch_started")
|
| 51 |
+
try:
|
| 52 |
+
import app.routers.x402_enforcement as _enf_mod
|
| 53 |
+
getter = getattr(_enf_mod, "get_facilitator_health", None)
|
| 54 |
+
if getter is None:
|
| 55 |
+
return self._default_facilitators()
|
| 56 |
+
raw = getter()
|
| 57 |
+
if asyncio.iscoroutine(raw):
|
| 58 |
+
raw = await raw
|
| 59 |
+
return self._wrap_facilitators(raw)
|
| 60 |
+
except Exception as e:
|
| 61 |
+
log.warning("x402_facilitators_fetch_failed", error=str(e))
|
| 62 |
+
return self._default_facilitators()
|
| 63 |
+
|
| 64 |
+
@staticmethod
|
| 65 |
+
def _default_facilitators() -> list[PaymentFacilitator]:
|
| 66 |
+
"""Default facilitator list — matches the legacy enforcement config."""
|
| 67 |
+
return [
|
| 68 |
+
PaymentFacilitator(name="Coinbase CDP", url="https://api.cdp.coinbase.com", enabled=True, chains=["base", "ethereum"], health="unknown"),
|
| 69 |
+
PaymentFacilitator(name="PayAI", url="https://payai.example", enabled=True, chains=["solana", "base"], health="unknown"),
|
| 70 |
+
PaymentFacilitator(name="Cloudflare x402", url="https://x402.cloudflare.com", enabled=True, chains=["base", "polygon"], health="unknown"),
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
@staticmethod
|
| 74 |
+
def _wrap_catalog(raw: Any) -> ToolCatalog:
|
| 75 |
+
"""Convert legacy catalog response → Pydantic ToolCatalog."""
|
| 76 |
+
tools: list[ToolCatalogEntry] = []
|
| 77 |
+
categories: set[str] = set()
|
| 78 |
+
if isinstance(raw, dict):
|
| 79 |
+
raw_tools = raw.get("tools", [])
|
| 80 |
+
total = raw.get("total", len(raw_tools))
|
| 81 |
+
categories = set(raw.get("categories", []))
|
| 82 |
+
elif isinstance(raw, list):
|
| 83 |
+
raw_tools = raw
|
| 84 |
+
total = len(raw)
|
| 85 |
+
else:
|
| 86 |
+
return ToolCatalog()
|
| 87 |
+
|
| 88 |
+
for t in raw_tools:
|
| 89 |
+
if isinstance(t, dict):
|
| 90 |
+
try:
|
| 91 |
+
tier_str = t.get("tier_required", t.get("tier", "free"))
|
| 92 |
+
entry = ToolCatalogEntry(
|
| 93 |
+
name=t.get("name", t.get("tool", "")),
|
| 94 |
+
description=t.get("description", ""),
|
| 95 |
+
category=t.get("category", ""),
|
| 96 |
+
tier_required=X402Tier(tier_str.lower() if isinstance(tier_str, str) else "free"),
|
| 97 |
+
price_usd=float(t.get("price_usd", 0) or 0),
|
| 98 |
+
endpoint=t.get("endpoint", ""),
|
| 99 |
+
enabled=bool(t.get("enabled", True)),
|
| 100 |
+
)
|
| 101 |
+
tools.append(entry)
|
| 102 |
+
if entry.category:
|
| 103 |
+
categories.add(entry.category)
|
| 104 |
+
except Exception:
|
| 105 |
+
continue
|
| 106 |
+
return ToolCatalog(
|
| 107 |
+
tools=tools,
|
| 108 |
+
total=total,
|
| 109 |
+
categories=sorted(categories),
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
@staticmethod
|
| 113 |
+
def _wrap_facilitators(raw: Any) -> list[PaymentFacilitator]:
|
| 114 |
+
"""Convert legacy facilitator list → Pydantic list."""
|
| 115 |
+
out: list[PaymentFacilitator] = []
|
| 116 |
+
items: list[dict] = []
|
| 117 |
+
if isinstance(raw, dict):
|
| 118 |
+
items = raw.get("facilitators", raw.get("data", []))
|
| 119 |
+
elif isinstance(raw, list):
|
| 120 |
+
items = raw
|
| 121 |
+
for f in items:
|
| 122 |
+
if isinstance(f, dict):
|
| 123 |
+
out.append(PaymentFacilitator(
|
| 124 |
+
name=f.get("name", ""),
|
| 125 |
+
url=f.get("url", ""),
|
| 126 |
+
enabled=bool(f.get("enabled", True)),
|
| 127 |
+
chains=f.get("chains", []) or [],
|
| 128 |
+
health=f.get("health", "unknown"),
|
| 129 |
+
))
|
| 130 |
+
return out
|
|
File without changes
|
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the x402 domain facade."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from unittest.mock import AsyncMock, patch
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from app.domain.x402 import (
|
| 9 |
+
PaymentFacilitator,
|
| 10 |
+
ToolCatalog,
|
| 11 |
+
ToolCatalogEntry,
|
| 12 |
+
X402Service,
|
| 13 |
+
X402Tier,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@pytest.fixture
|
| 18 |
+
def service() -> X402Service:
|
| 19 |
+
return X402Service()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ── Service: catalog ──────────────────────────────────────────────────
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
async def test_catalog_wraps_dict_shape(service):
|
| 26 |
+
raw = {
|
| 27 |
+
"tools": [
|
| 28 |
+
{"name": "scan_token", "category": "scanner", "tier": "pro", "price_usd": 0.05},
|
| 29 |
+
{"name": "wallet_analyze", "category": "wallet", "tier_required": "elite", "price_usd": 0.10},
|
| 30 |
+
],
|
| 31 |
+
"total": 2,
|
| 32 |
+
"categories": ["scanner", "wallet"],
|
| 33 |
+
}
|
| 34 |
+
with patch("app.routers.x402_catalog.list_tools_catalog", new=AsyncMock(return_value=raw)):
|
| 35 |
+
cat = await service.get_catalog()
|
| 36 |
+
assert cat.total == 2
|
| 37 |
+
assert len(cat.tools) == 2
|
| 38 |
+
assert cat.tools[0].name == "scan_token"
|
| 39 |
+
assert cat.tools[0].tier_required == X402Tier.PRO
|
| 40 |
+
assert cat.tools[1].tier_required == X402Tier.ELITE
|
| 41 |
+
assert "scanner" in cat.categories
|
| 42 |
+
assert "wallet" in cat.categories
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
async def test_catalog_wraps_list_shape(service):
|
| 46 |
+
raw = [
|
| 47 |
+
{"name": "t1", "category": "scanner", "tier_required": "free"},
|
| 48 |
+
{"name": "t2", "category": "wallet", "tier_required": "pro"},
|
| 49 |
+
]
|
| 50 |
+
with patch("app.routers.x402_catalog.list_tools_catalog", new=AsyncMock(return_value=raw)):
|
| 51 |
+
cat = await service.get_catalog()
|
| 52 |
+
assert cat.total == 2
|
| 53 |
+
assert len(cat.tools) == 2
|
| 54 |
+
assert {t.name for t in cat.tools} == {"t1", "t2"}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def test_catalog_handles_legacy_failure(service):
|
| 58 |
+
with patch("app.routers.x402_catalog.list_tools_catalog", new=AsyncMock(side_effect=Exception("boom"))):
|
| 59 |
+
cat = await service.get_catalog()
|
| 60 |
+
assert cat.total == 0
|
| 61 |
+
assert cat.tools == []
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def test_catalog_skips_malformed_entries(service):
|
| 65 |
+
raw = {
|
| 66 |
+
"tools": [
|
| 67 |
+
{"name": "good", "category": "x", "tier_required": "free"},
|
| 68 |
+
{"name": "bad_missing_required"},
|
| 69 |
+
"not_a_dict",
|
| 70 |
+
],
|
| 71 |
+
"total": 3,
|
| 72 |
+
}
|
| 73 |
+
with patch("app.routers.x402_catalog.list_tools_catalog", new=AsyncMock(return_value=raw)):
|
| 74 |
+
cat = await service.get_catalog()
|
| 75 |
+
assert any(t.name == "good" for t in cat.tools)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ── Service: facilitators (no legacy function — uses defaults) ───────
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
async def test_facilitators_returns_default_list(service):
|
| 82 |
+
"""When no legacy getter exists, return the default facilitator list."""
|
| 83 |
+
facs = await service.get_facilitators()
|
| 84 |
+
assert len(facs) >= 1
|
| 85 |
+
assert all(isinstance(f, PaymentFacilitator) for f in facs)
|
| 86 |
+
names = [f.name for f in facs]
|
| 87 |
+
assert any("Coinbase" in n or "PayAI" in n or "Cloudflare" in n for n in names)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
async def test_facilitators_handles_failure(service):
|
| 91 |
+
"""Legacy getter raises → return defaults."""
|
| 92 |
+
import types
|
| 93 |
+
|
| 94 |
+
def _explode():
|
| 95 |
+
raise Exception("boom")
|
| 96 |
+
|
| 97 |
+
fake_mod = types.SimpleNamespace(get_facilitator_health=_explode)
|
| 98 |
+
with patch.dict("sys.modules", {"app.routers.x402_enforcement": fake_mod}):
|
| 99 |
+
facs = await service.get_facilitators()
|
| 100 |
+
assert len(facs) >= 1 # defaults returned
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ── Tier enum ─────────────────────────────────────────────────────────
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_tier_enum_values():
|
| 107 |
+
assert "free" in [t.value for t in X402Tier]
|
| 108 |
+
assert "pro" in [t.value for t in X402Tier]
|
| 109 |
+
assert "elite" in [t.value for t in X402Tier]
|
| 110 |
+
assert "internal" in [t.value for t in X402Tier]
|