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"