cryptorugmuncher commited on
Commit
33e69dd
·
1 Parent(s): c3c64cd

feat(x402): vertical slice — Pydantic facade over legacy x402 payment system

Browse files

app/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 CHANGED
@@ -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."""
backend/app/api/v1/x402/payments.py ADDED
@@ -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()
backend/app/domain/x402/__init__.py ADDED
@@ -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
+ ]
backend/app/domain/x402/models.py ADDED
@@ -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 = ""
backend/app/domain/x402/repository.py ADDED
@@ -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)
backend/app/domain/x402/service.py ADDED
@@ -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
backend/tests/unit/domain/x402/__init__.py ADDED
File without changes
backend/tests/unit/domain/x402/test_service.py ADDED
@@ -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]