From 4472e9d702984097b257617f934076debb2f4d25 Mon Sep 17 00:00:00 2001 From: Bortlesboat Date: Thu, 2 Apr 2026 12:08:20 -0400 Subject: [PATCH] feat(python): add facilitator HTTP server Add create_facilitator_app() factory that wraps x402Facilitator or x402FacilitatorSync in a FastAPI application exposing the standard /supported, /verify, /settle, and /health endpoints. This brings the Python SDK to parity with the Go SDK's facilitator hosting capabilities. Supports both async and sync facilitators (sync runs in thread pool), optional CORS configuration, and V1/V2 protocol version routing. Includes 20 unit tests covering all endpoints, error handling, CORS, both facilitator variants, and response schema round-trip validation. --- .../changelog.d/facilitator-server.feature.md | 1 + python/x402/http/facilitator_server.py | 234 +++++++++++++ .../unit/http/test_facilitator_server.py | 327 ++++++++++++++++++ 3 files changed, 562 insertions(+) create mode 100644 python/x402/changelog.d/facilitator-server.feature.md create mode 100644 python/x402/http/facilitator_server.py create mode 100644 python/x402/tests/unit/http/test_facilitator_server.py diff --git a/python/x402/changelog.d/facilitator-server.feature.md b/python/x402/changelog.d/facilitator-server.feature.md new file mode 100644 index 0000000000..1e6c8ec74e --- /dev/null +++ b/python/x402/changelog.d/facilitator-server.feature.md @@ -0,0 +1 @@ +Added facilitator HTTP server module for hosting x402 facilitator endpoints in Python. Provides `create_facilitator_app()` factory that wraps any `x402Facilitator` or `x402FacilitatorSync` in a FastAPI application with `/supported`, `/verify`, `/settle`, and `/health` endpoints. Brings Python SDK to parity with Go SDK facilitator hosting capabilities. diff --git a/python/x402/http/facilitator_server.py b/python/x402/http/facilitator_server.py new file mode 100644 index 0000000000..913dc92ff4 --- /dev/null +++ b/python/x402/http/facilitator_server.py @@ -0,0 +1,234 @@ +"""HTTP facilitator server for x402 protocol. + +Wraps an x402Facilitator (async or sync) in a FastAPI application that exposes +the standard facilitator endpoints: /supported, /verify, /settle. + +This is the server-side counterpart to HTTPFacilitatorClient. While the client +sends requests to a remote facilitator, this module hosts one locally. + +Example: + ```python + from x402 import x402Facilitator + from x402.mechanisms.evm.exact import ExactEvmFacilitatorScheme + from x402.http.facilitator_server import create_facilitator_app + + facilitator = x402Facilitator() + facilitator.register( + ["eip155:8453"], + ExactEvmFacilitatorScheme(wallet=wallet), + ) + + app = create_facilitator_app(facilitator) + + # Run with uvicorn + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8402) + ``` +""" + +import asyncio +import json +import logging +from typing import Any + +from ..facilitator import x402Facilitator, x402FacilitatorSync +from ..schemas import ( + PaymentPayload, + PaymentRequirements, + SchemeNotFoundError, + SettleResponse, + SupportedResponse, + VerifyResponse, +) +from ..schemas.v1 import PaymentPayloadV1, PaymentRequirementsV1 + +logger = logging.getLogger(__name__) + + +def _parse_request_body( + body: dict[str, Any], +) -> tuple[ + int, + PaymentPayload | PaymentPayloadV1, + PaymentRequirements | PaymentRequirementsV1, +]: + """Parse verify/settle request body into typed objects. + + Handles both V1 and V2 protocol versions based on x402Version field. + + Args: + body: Raw JSON body with x402Version, paymentPayload, paymentRequirements. + + Returns: + Tuple of (version, payload, requirements). + + Raises: + ValueError: If body is missing required fields or version is unsupported. + """ + version = body.get("x402Version") + if version is None: + raise ValueError("Missing x402Version in request body") + + raw_payload = body.get("paymentPayload") + raw_requirements = body.get("paymentRequirements") + + if raw_payload is None or raw_requirements is None: + raise ValueError("Missing paymentPayload or paymentRequirements in request body") + + if version == 1: + payload = PaymentPayloadV1.model_validate(raw_payload) + requirements = PaymentRequirementsV1.model_validate(raw_requirements) + else: + payload = PaymentPayload.model_validate(raw_payload) + requirements = PaymentRequirements.model_validate(raw_requirements) + + return version, payload, requirements + + +def create_facilitator_app( + facilitator: x402Facilitator | x402FacilitatorSync, + *, + title: str = "x402 Facilitator", + description: str = "x402 payment verification and settlement service", + cors_origins: list[str] | None = None, +) -> Any: + """Create a FastAPI application wrapping an x402 facilitator. + + The returned app exposes the standard facilitator HTTP endpoints: + - GET /supported — capability discovery + - POST /verify — payment verification + - POST /settle — payment settlement + - GET /health — liveness check + + Works with both async (x402Facilitator) and sync (x402FacilitatorSync) + facilitator instances. Sync facilitators run in a thread pool to avoid + blocking the event loop. + + Args: + facilitator: Configured x402Facilitator or x402FacilitatorSync instance. + title: OpenAPI title for the FastAPI app. + description: OpenAPI description. + cors_origins: Optional list of CORS allowed origins. If provided, + CORSMiddleware is added to the app. + + Returns: + A FastAPI application ready to serve with uvicorn. + + Raises: + ImportError: If fastapi is not installed. + """ + try: + from fastapi import FastAPI, Request + from fastapi.responses import JSONResponse + except ImportError as e: + raise ImportError( + "fastapi is required for the facilitator server. " + "Install with: pip install 'x402[fastapi]'" + ) from e + + is_async = isinstance(facilitator, x402Facilitator) + + app = FastAPI(title=title, description=description) + + if cors_origins: + from starlette.middleware.cors import CORSMiddleware + + app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_methods=["GET", "POST"], + allow_headers=["*"], + ) + + @app.get("/supported") + def get_supported() -> dict[str, Any]: + """Return supported payment kinds, extensions, and signers.""" + supported: SupportedResponse = facilitator.get_supported() + return supported.model_dump(by_alias=True, exclude_none=True) + + @app.post("/verify") + async def verify(request: Request) -> JSONResponse: + """Verify a payment payload against requirements.""" + try: + body = await request.json() + except (json.JSONDecodeError, ValueError): + return JSONResponse( + status_code=400, + content={"error": "Invalid JSON in request body"}, + ) + + try: + _, payload, requirements = _parse_request_body(body) + except Exception as e: + return JSONResponse( + status_code=400, + content={"error": f"Invalid request: {e}"}, + ) + + try: + if is_async: + result: VerifyResponse = await facilitator.verify(payload, requirements) + else: + result = await asyncio.to_thread(facilitator.verify, payload, requirements) + except SchemeNotFoundError as e: + return JSONResponse( + status_code=400, + content={"error": f"Unsupported scheme/network: {e}"}, + ) + except Exception as e: + logger.exception("Verify failed unexpectedly") + return JSONResponse( + status_code=500, + content={"error": f"Internal error: {e}"}, + ) + + return JSONResponse( + content=result.model_dump(by_alias=True, exclude_none=True), + ) + + @app.post("/settle") + async def settle(request: Request) -> JSONResponse: + """Settle a verified payment.""" + try: + body = await request.json() + except (json.JSONDecodeError, ValueError): + return JSONResponse( + status_code=400, + content={"error": "Invalid JSON in request body"}, + ) + + try: + _, payload, requirements = _parse_request_body(body) + except Exception as e: + return JSONResponse( + status_code=400, + content={"error": f"Invalid request: {e}"}, + ) + + try: + if is_async: + result: SettleResponse = await facilitator.settle(payload, requirements) + else: + result = await asyncio.to_thread(facilitator.settle, payload, requirements) + except SchemeNotFoundError as e: + return JSONResponse( + status_code=400, + content={"error": f"Unsupported scheme/network: {e}"}, + ) + except Exception as e: + logger.exception("Settle failed unexpectedly") + return JSONResponse( + status_code=500, + content={"error": f"Internal error: {e}"}, + ) + + return JSONResponse( + content=result.model_dump(by_alias=True, exclude_none=True), + ) + + @app.get("/health") + def health() -> dict[str, str]: + """Liveness check.""" + return {"status": "ok"} + + return app diff --git a/python/x402/tests/unit/http/test_facilitator_server.py b/python/x402/tests/unit/http/test_facilitator_server.py new file mode 100644 index 0000000000..5b89588d73 --- /dev/null +++ b/python/x402/tests/unit/http/test_facilitator_server.py @@ -0,0 +1,327 @@ +"""Unit tests for x402.http.facilitator_server.""" + +from __future__ import annotations + +import pytest +from starlette.testclient import TestClient + +from x402.facilitator import x402Facilitator, x402FacilitatorSync +from x402.http.facilitator_server import create_facilitator_app +from x402.interfaces import FacilitatorContext +from x402.schemas import ( + PaymentPayload, + PaymentRequirements, + SettleResponse, + SupportedResponse, + VerifyResponse, +) + +# ============================================================================ +# Mock Scheme Facilitator +# ============================================================================ + + +class MockExactScheme: + """Mock facilitator scheme for testing.""" + + scheme = "exact" + caip_family = "eip155:*" + + def get_extra(self, network: str) -> dict | None: + return None + + def get_signers(self, network: str) -> list[str]: + return ["0xfacilitator"] + + def verify( + self, + payload: PaymentPayload, + requirements: PaymentRequirements, + context: FacilitatorContext | None = None, + ) -> VerifyResponse: + sig = payload.payload.get("signature", "") + if sig == "valid": + return VerifyResponse(is_valid=True, payer="0xpayer") + return VerifyResponse( + is_valid=False, + invalid_reason="bad_signature", + invalid_message="Invalid signature provided", + ) + + def settle( + self, + payload: PaymentPayload, + requirements: PaymentRequirements, + context: FacilitatorContext | None = None, + ) -> SettleResponse: + sig = payload.payload.get("signature", "") + if sig == "valid": + return SettleResponse( + success=True, + transaction="0xtxhash", + network=requirements.network, + payer="0xpayer", + ) + return SettleResponse( + success=False, + transaction="", + network=requirements.network, + error_reason="settlement_failed", + ) + + +# ============================================================================ +# Fixtures +# ============================================================================ + +MOCK_NETWORK = "eip155:8453" + + +def _make_requirements() -> dict: + return { + "scheme": "exact", + "network": MOCK_NETWORK, + "asset": "0x0000000000000000000000000000000000000000", + "amount": "1000000", + "payTo": "0x1234567890123456789012345678901234567890", + "maxTimeoutSeconds": 300, + } + + +def _make_payload(signature: str = "valid") -> dict: + return { + "x402Version": 2, + "payload": {"signature": signature}, + "accepted": _make_requirements(), + } + + +def _make_request_body(signature: str = "valid") -> dict: + return { + "x402Version": 2, + "paymentPayload": _make_payload(signature), + "paymentRequirements": _make_requirements(), + } + + +def _create_sync_facilitator() -> x402FacilitatorSync: + f = x402FacilitatorSync() + f.register([MOCK_NETWORK], MockExactScheme()) + return f + + +def _create_async_facilitator() -> x402Facilitator: + f = x402Facilitator() + f.register([MOCK_NETWORK], MockExactScheme()) + return f + + +@pytest.fixture() +def sync_client() -> TestClient: + """TestClient with a sync facilitator.""" + app = create_facilitator_app(_create_sync_facilitator()) + return TestClient(app) + + +@pytest.fixture() +def async_client() -> TestClient: + """TestClient with an async facilitator.""" + app = create_facilitator_app(_create_async_facilitator()) + return TestClient(app) + + +# ============================================================================ +# /supported +# ============================================================================ + + +class TestSupported: + def test_returns_registered_kinds(self, sync_client: TestClient) -> None: + resp = sync_client.get("/supported") + assert resp.status_code == 200 + data = resp.json() + assert "kinds" in data + assert len(data["kinds"]) == 1 + kind = data["kinds"][0] + assert kind["scheme"] == "exact" + assert kind["network"] == MOCK_NETWORK + assert kind["x402Version"] == 2 + + def test_returns_signers(self, sync_client: TestClient) -> None: + resp = sync_client.get("/supported") + data = resp.json() + assert "signers" in data + assert "eip155:*" in data["signers"] + assert "0xfacilitator" in data["signers"]["eip155:*"] + + def test_returns_extensions(self, sync_client: TestClient) -> None: + resp = sync_client.get("/supported") + data = resp.json() + assert "extensions" in data + assert data["extensions"] == [] + + +# ============================================================================ +# /verify +# ============================================================================ + + +class TestVerify: + def test_valid_payment(self, sync_client: TestClient) -> None: + resp = sync_client.post("/verify", json=_make_request_body("valid")) + assert resp.status_code == 200 + data = resp.json() + assert data["isValid"] is True + assert data["payer"] == "0xpayer" + + def test_invalid_payment(self, sync_client: TestClient) -> None: + resp = sync_client.post("/verify", json=_make_request_body("bad")) + assert resp.status_code == 200 + data = resp.json() + assert data["isValid"] is False + assert data["invalidReason"] == "bad_signature" + + def test_missing_payload(self, sync_client: TestClient) -> None: + resp = sync_client.post("/verify", json={"x402Version": 2}) + assert resp.status_code == 400 + + def test_missing_version(self, sync_client: TestClient) -> None: + body = _make_request_body() + del body["x402Version"] + resp = sync_client.post("/verify", json=body) + assert resp.status_code == 400 + + def test_invalid_json(self, sync_client: TestClient) -> None: + resp = sync_client.post( + "/verify", + content=b"not json", + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 400 + + def test_unsupported_network(self, sync_client: TestClient) -> None: + body = _make_request_body() + # Use a completely different chain family (not eip155) + body["paymentPayload"]["accepted"]["network"] = "solana:mainnet" + body["paymentPayload"]["accepted"]["scheme"] = "exact" + body["paymentRequirements"]["network"] = "solana:mainnet" + resp = sync_client.post("/verify", json=body) + assert resp.status_code == 400 + assert "Unsupported" in resp.json().get("error", "") + + def test_async_facilitator(self, async_client: TestClient) -> None: + resp = async_client.post("/verify", json=_make_request_body("valid")) + assert resp.status_code == 200 + data = resp.json() + assert data["isValid"] is True + + +# ============================================================================ +# /settle +# ============================================================================ + + +class TestSettle: + def test_successful_settlement(self, sync_client: TestClient) -> None: + resp = sync_client.post("/settle", json=_make_request_body("valid")) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["transaction"] == "0xtxhash" + assert data["network"] == MOCK_NETWORK + assert data["payer"] == "0xpayer" + + def test_failed_settlement(self, sync_client: TestClient) -> None: + resp = sync_client.post("/settle", json=_make_request_body("bad")) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert data["errorReason"] == "settlement_failed" + + def test_missing_payload(self, sync_client: TestClient) -> None: + resp = sync_client.post("/settle", json={"x402Version": 2}) + assert resp.status_code == 400 + + def test_async_facilitator(self, async_client: TestClient) -> None: + resp = async_client.post("/settle", json=_make_request_body("valid")) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + + +# ============================================================================ +# /health +# ============================================================================ + + +class TestHealth: + def test_returns_ok(self, sync_client: TestClient) -> None: + resp = sync_client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +# ============================================================================ +# CORS +# ============================================================================ + + +class TestCors: + def test_cors_headers_when_configured(self) -> None: + app = create_facilitator_app( + _create_sync_facilitator(), + cors_origins=["https://example.com"], + ) + client = TestClient(app) + resp = client.options( + "/supported", + headers={ + "Origin": "https://example.com", + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.headers.get("access-control-allow-origin") == "https://example.com" + + def test_no_cors_by_default(self, sync_client: TestClient) -> None: + resp = sync_client.get( + "/supported", + headers={"Origin": "https://evil.com"}, + ) + assert "access-control-allow-origin" not in resp.headers + + +# ============================================================================ +# Round-trip: client ↔ server +# ============================================================================ + + +class TestRoundTrip: + """Verify the server produces responses the client can parse.""" + + def test_verify_response_matches_client_schema(self, sync_client: TestClient) -> None: + """Verify response can be parsed by the same VerifyResponse model the client uses.""" + resp = sync_client.post("/verify", json=_make_request_body("valid")) + assert resp.status_code == 200 + + # Parse with the same model the HTTPFacilitatorClient uses + result = VerifyResponse.model_validate(resp.json()) + assert result.is_valid is True + assert result.payer == "0xpayer" + + def test_settle_response_matches_client_schema(self, sync_client: TestClient) -> None: + """Settle response can be parsed by the same SettleResponse model the client uses.""" + resp = sync_client.post("/settle", json=_make_request_body("valid")) + assert resp.status_code == 200 + + result = SettleResponse.model_validate(resp.json()) + assert result.success is True + assert result.transaction == "0xtxhash" + + def test_supported_response_matches_client_schema(self, sync_client: TestClient) -> None: + """Supported response can be parsed by the same SupportedResponse model.""" + resp = sync_client.get("/supported") + assert resp.status_code == 200 + + result = SupportedResponse.model_validate(resp.json()) + assert len(result.kinds) == 1 + assert result.kinds[0].scheme == "exact"