From 5fa92e08f77be136c3e105dd14ea756d1f2e71d4 Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Thu, 1 Oct 2026 16:52:06 -0700 Subject: [PATCH] docs: add runnable payment examples --- README.md | 3 + examples/README.md | 147 +++++++++++++++++++++ examples/__init__.py | 1 + examples/mpp_buyer.py | 56 ++++++++ examples/mpp_seller.py | 75 +++++++++++ examples/x402_buyer.py | 63 +++++++++ examples/x402_seller.py | 75 +++++++++++ pyproject.toml | 9 +- scripts/verify_distribution.py | 25 ++++ tests/test_examples.py | 234 +++++++++++++++++++++++++++++++++ uv.lock | 6 +- 11 files changed, 689 insertions(+), 5 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/__init__.py create mode 100644 examples/mpp_buyer.py create mode 100644 examples/mpp_seller.py create mode 100644 examples/x402_buyer.py create mode 100644 examples/x402_seller.py create mode 100644 tests/test_examples.py diff --git a/README.md b/README.md index f884b09..76e231c 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ Python integration for InFlow payments using the Machine Payments Protocol (MPP) and x402. The Python distribution and import namespace are both `inflowpay`. Python 3.11 or newer is required. +Start with the [runnable Sandbox examples](examples/README.md) for MPP and x402 +Buyers and Sellers, including account setup, commands, and expected results. + ## Working with the repository Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then run: diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..46f7053 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,147 @@ +# Run a Sandbox payment + +These four programs connect to **InFlow Sandbox**. The Sellers run on your computer; +configuration, approvals, and payments use Sandbox accounts. They are not simulated +payments. Run the commands from the repository root with Python 3.11 or newer. + +## Set up your accounts + +1. Register at [InFlow Sandbox](https://sandbox.inflowpay.ai). Accepting payments + requires a **Seller** account and an API key from its dashboard. A Developer + key cannot be used as a Seller key. +2. Use a separate account and key for the Buyer so the two sides are easy to follow. + A Developer account can act as a Buyer; Seller accounts can also buy. +3. For a balance-funded request, have at least **0.01 USDC** in the Buyer's + [Sandbox Balances](https://sandbox.inflowpay.ai/balances/). Use + [Deposit](https://sandbox.inflowpay.ai/transactions/deposit/) to choose USDC and + an available network. Use test assets on the network configured for Sandbox, + not mainnet funds. Wait for the balance to be credited. If USDC or a deposit + network is unavailable, resolve that account setup before paying. An API key + does not provide funds or guarantee approval. +4. Install [uv](https://docs.astral.sh/uv/getting-started/installation/) and run: + + ```sh + make sync + ``` + +This installs the locked dependencies, including FastAPI, Uvicorn, HTTPX, and both +payment libraries. For an application outside this checkout, install +`inflowpay[mpp,fastapi]` or `inflowpay[x402,fastapi]` and `uvicorn` for a Seller; +Buyers need only the corresponding `inflowpay[mpp]` or `inflowpay[x402]` extra. + +## MPP + +Start the [Seller](mpp_seller.py) in one terminal. Generate a private challenge key +once; keep it separate from the InFlow API key: + +```sh +export INFLOW_API_KEY='your-sandbox-seller-key' +export MPP_SECRET_KEY="$(uv run python -c 'import secrets; print(secrets.token_hex(32))')" +uv run --locked python -m examples.mpp_seller +``` + +The server listens at `http://127.0.0.1:3000`. Check both routes without paying: + +```sh +curl -i http://127.0.0.1:3000/free +curl -i http://127.0.0.1:3000/api/widgets +``` + +`/free` returns HTTP 200 and `{"ok":true}`. `/api/widgets` returns HTTP 402 with +a `WWW-Authenticate: Payment ...` challenge for **0.01 USDC**. + +In a second terminal, run the [Buyer](mpp_buyer.py): + +```sh +export INFLOW_API_KEY='your-sandbox-buyer-key' +uv run --locked python -m examples.mpp_buyer +``` + +Keep the [Sandbox Approvals](https://sandbox.inflowpay.ai/approvals/) page open and +approve if requested. The Buyer prints HTTP 200, `{"widgets":[1,2,3]}`, and the +method and reference decoded from the Seller's `Payment-Receipt`. + +The Buyer obtains a payment credential through InFlow and retries the resource +once. The Seller verifies and broadcasts the payment before its widgets handler +runs. This example uses one charge offer. MPP Seller subscriptions and composed +InFlow offers are not supported by this integration; see the +[upstream limitations](../README.md#seller-route-limitations). + +## x402 + +Start the [Seller](x402_seller.py) in one terminal: + +```sh +export INFLOW_API_KEY='your-sandbox-seller-key' +uv run --locked python -m examples.x402_seller +``` + +It listens at `http://127.0.0.1:3001` and builds **0.01 USDC** offers from the +Seller's configured balance and exact payment methods. It stops at startup if no +matching offers are available. Check the routes without paying: + +```sh +curl -i http://127.0.0.1:3001/free +curl -i http://127.0.0.1:3001/api/widgets +``` + +`/free` returns HTTP 200. `/api/widgets` returns HTTP 402 with a `PAYMENT-REQUIRED` +header describing the available offers. + +Run the [Buyer](x402_buyer.py) in a second terminal: + +```sh +export INFLOW_API_KEY='your-sandbox-buyer-key' +uv run --locked python -m examples.x402_buyer +``` + +Approve in [Sandbox Approvals](https://sandbox.inflowpay.ai/approvals/) if requested. +Success prints HTTP 200, `{"widgets":[1,2,3]}`, and the success, network, and +transaction fields decoded from `PAYMENT-RESPONSE`. + +The Buyer selects a supported offer, obtains its payment payload through InFlow, +and sends one paid request. The Seller verifies it, runs the widgets handler, and +settles before releasing the response. The handler only returns content: payment +middleware does not make database writes or other application side effects atomic +with settlement. No external wallet or retry-recovery hook is registered here. + +## Settings and safe testing + +| Variable | Used by | Meaning | +| ----------------- | ---------- | ----------------------------------------------------------- | +| `INFLOW_API_KEY` | All | Sandbox key; Sellers require a Seller account key | +| `MPP_SECRET_KEY` | MPP Seller | Private key for signing challenges | +| `TARGET_URL` | Buyers | Defaults to the matching local Seller's `/api/widgets` | +| `INFLOW_BASE_URL` | All | Optional platform override; leave unset to use Sandbox | + +The programs read exported variables, not `.env` files. Never commit real keys. +Leave `INFLOW_BASE_URL` unset unless intentionally testing a private deployment: +the API key is sent there. It is not sent to `TARGET_URL`. + +Each Buyer allows up to fifteen minutes for its resource request and payment flow. +Press Ctrl-C to cancel. A pending approval is cancelled when the SDK has its +identifier; cancellation does not reverse a completed payment. A second 402, a +failed settlement receipt, or another HTTP error stops the program rather than +starting another payment. After a timeout or network error, check the account's +transactions before deciding whether to retry. + +To exercise a free response, set `TARGET_URL` to the Seller's `/free` route. +The Buyer reports that no receipt was returned. A missing receipt alone does not +prove that no payment occurred; decoding a receipt also does not independently +verify settlement. These programs print neither payment credentials nor signatures. + +The Sellers bind only to loopback and have no application login. Add your own +application authentication when required; paying is not a substitute for logging in. + +## Adapt the examples + +- [Manual MPP waiting and cancellation](../README.md#waiting-errors-and-shutdown) +- [Displaying an x402 approval before waiting](../README.md#show-an-approval-before-waiting) +- [Metered x402 settlement](../README.md#prices-payment-methods-and-metering) +- [External wallets](../README.md#external-wallets) and + [EIP-7702 sponsorship](../README.md#eip-7702-sponsorship) + +`make verify` checks the example code's formatting, typing, line and branch coverage, +and Buyer-to-Seller flows through the actual payment middleware. Platform responses +in these tests are scripted; they do not establish live settlement. The commands +above are the walkthrough for checking your own Sandbox configuration. diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e63caad --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1 @@ +"""Runnable Sandbox payment examples.""" diff --git a/examples/mpp_buyer.py b/examples/mpp_buyer.py new file mode 100644 index 0000000..d617fa6 --- /dev/null +++ b/examples/mpp_buyer.py @@ -0,0 +1,56 @@ +import asyncio +import os +import sys + +import httpx + +from inflowpay import ClientOptions +from inflowpay.mpp import decode_receipt +from inflowpay.mpp.buyer import BuyerMethod, payment_transport + + +async def run() -> None: + key = os.environ.get("INFLOW_API_KEY") + if not key: + raise ValueError("Set INFLOW_API_KEY to your Sandbox buyer API key.") + target = os.environ.get("TARGET_URL", "http://127.0.0.1:3000/api/widgets") + print("Requesting resource; approve in the Sandbox dashboard if requested.", flush=True) + # The platform key belongs to BuyerMethod, never to the merchant HTTP client. + async with ( + BuyerMethod( + ClientOptions( + environment="sandbox", api_key=key, base_url=os.environ.get("INFLOW_BASE_URL") + ) + ) as method, + httpx.AsyncClient(transport=payment_transport([method]), follow_redirects=False) as http, + ): + async with asyncio.timeout(900): + response = await http.get(target) + print(f"HTTP {response.status_code}\n{response.text}") + receipt = response.headers.get("payment-receipt") + if receipt is not None: + decoded = decode_receipt(receipt) + print(f"Seller receipt: {decoded['method']} reference={decoded['reference']}") + else: + print("No seller receipt returned; this does not establish whether payment occurred.") + # A second 402 is an error, not permission to purchase again. + response.raise_for_status() + + +def main() -> int: + try: + asyncio.run(run()) + except KeyboardInterrupt: + print("Cancelled. A completed payment is not reversed.", file=sys.stderr) + return 130 + except Exception as error: + print( + f"Request failed: {error}. Do not automatically retry an uncertain payment.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/mpp_seller.py b/examples/mpp_seller.py new file mode 100644 index 0000000..f3b9478 --- /dev/null +++ b/examples/mpp_seller.py @@ -0,0 +1,75 @@ +import asyncio +import os +import sys +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import uvicorn +from fastapi import FastAPI, Request +from mpp import Credential, Receipt +from mpp.server.decorator import pay +from starlette.responses import JSONResponse + +from inflowpay import ClientOptions +from inflowpay.mpp.seller import Seller + + +@asynccontextmanager +async def application(options: ClientOptions, secret: str) -> AsyncIterator[FastAPI]: + # Keep the Seller open for the whole server lifetime, not just startup. + async with await Seller.create(options) as seller: + app = FastAPI() + + @app.get("/api/widgets") + @pay( + intent=seller, + method=seller.method, + request=seller.charge_request({"amount": "0.01", "currency": "USDC"}), + realm="localhost", + secret_key=secret, + ) + async def widgets( + request: Request, credential: Credential, receipt: Receipt + ) -> JSONResponse: + # pympp verifies and broadcasts before reaching this handler. + return JSONResponse( + {"widgets": [1, 2, 3]}, headers={"Payment-Receipt": receipt.to_payment_receipt()} + ) + + @app.get("/free") + async def free() -> dict[str, bool]: + return {"ok": True} + + yield app + + +async def run() -> None: + key, secret = os.environ.get("INFLOW_API_KEY"), os.environ.get("MPP_SECRET_KEY") + if not key or not secret: + raise ValueError( + "Set INFLOW_API_KEY (Sandbox Seller key) and MPP_SECRET_KEY (private challenge key)." + ) + options = ClientOptions( + environment="sandbox", api_key=key, base_url=os.environ.get("INFLOW_BASE_URL") + ) + async with application(options, secret) as app: + print( + "MPP: http://127.0.0.1:3000/api/widgets costs 0.01 USDC; /free requires no payment.", + flush=True, + ) + await uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=3000)).serve() + + +def main() -> int: + try: + asyncio.run(run()) + except KeyboardInterrupt: + return 130 + except Exception as error: + print(f"Seller failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/x402_buyer.py b/examples/x402_buyer.py new file mode 100644 index 0000000..7cb988d --- /dev/null +++ b/examples/x402_buyer.py @@ -0,0 +1,63 @@ +import asyncio +import os +import sys + +import httpx +from x402.http import decode_payment_response_header +from x402.http.clients.httpx import x402AsyncTransport + +from inflowpay import ClientOptions +from inflowpay.x402.buyer import Buyer + + +async def run() -> None: + key = os.environ.get("INFLOW_API_KEY") + if not key: + raise ValueError("Set INFLOW_API_KEY to your Sandbox buyer API key.") + target = os.environ.get("TARGET_URL", "http://127.0.0.1:3001/api/widgets") + print("Requesting resource; approve in the Sandbox dashboard if requested.", flush=True) + # No external wallet or recovery hook is registered: a failed paid retry stops here. + async with ( + await Buyer.create( + ClientOptions( + environment="sandbox", api_key=key, base_url=os.environ.get("INFLOW_BASE_URL") + ) + ) as buyer, + httpx.AsyncClient(transport=x402AsyncTransport(buyer), follow_redirects=False) as http, + ): + async with asyncio.timeout(900): + response = await http.get(target) + print(f"HTTP {response.status_code}\n{response.text}") + receipt = response.headers.get("payment-response") or response.headers.get( + "x-payment-response" + ) + if receipt is not None: + settled = decode_payment_response_header(receipt) + print( + f"Seller settlement: success={settled.success} network={settled.network} " + f"transaction={settled.transaction}" + ) + if not settled.success: + raise ValueError(f"Settlement failed: {settled.error_reason}") + else: + print("No seller receipt returned; this does not establish whether payment occurred.") + response.raise_for_status() + + +def main() -> int: + try: + asyncio.run(run()) + except KeyboardInterrupt: + print("Cancelled. A completed payment is not reversed.", file=sys.stderr) + return 130 + except Exception as error: + print( + f"Request failed: {error}. Do not automatically retry an uncertain payment.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/x402_seller.py b/examples/x402_seller.py new file mode 100644 index 0000000..4c04137 --- /dev/null +++ b/examples/x402_seller.py @@ -0,0 +1,75 @@ +import asyncio +import os +import sys +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import uvicorn +from fastapi import FastAPI +from x402 import x402ResourceServer +from x402.http.middleware.fastapi import payment_middleware +from x402.http.types import RouteConfig + +from inflowpay import ClientOptions +from inflowpay.x402.facilitator import Facilitator +from inflowpay.x402.seller import Seller + + +@asynccontextmanager +async def application(options: ClientOptions) -> AsyncIterator[FastAPI]: + # Each client owns its HTTP connection pool; keep both open while serving. + async with ( + await Seller.create(options) as seller, + await Facilitator.create(options) as facilitator, + ): + resource = x402ResourceServer(facilitator) + for registration in await seller.scheme_registrations(): + resource.register(registration["network"], registration["server"]) + offers = await seller.offers("0.01 USDC", schemes=["balance", "exact"]) + if not offers: + raise ValueError("The Seller configuration has no USDC payment offers.") + app = FastAPI() + app.middleware("http")( + payment_middleware({"GET /api/widgets": RouteConfig(accepts=offers)}, resource) + ) + + @app.get("/api/widgets") + async def widgets() -> dict[str, list[int]]: + # Settlement follows this response; only return content, not irreversible side effects. + return {"widgets": [1, 2, 3]} + + @app.get("/free") + async def free() -> dict[str, bool]: + return {"ok": True} + + yield app + + +async def run() -> None: + key = os.environ.get("INFLOW_API_KEY") + if not key: + raise ValueError("Set INFLOW_API_KEY to your Sandbox Seller API key.") + options = ClientOptions( + environment="sandbox", api_key=key, base_url=os.environ.get("INFLOW_BASE_URL") + ) + async with application(options) as app: + print( + "x402: http://127.0.0.1:3001/api/widgets costs 0.01 USDC; /free requires no payment.", + flush=True, + ) + await uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=3001)).serve() + + +def main() -> int: + try: + asyncio.run(run()) + except KeyboardInterrupt: + return 130 + except Exception as error: + print(f"Seller failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index 47f9dfe..30758fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,10 +45,11 @@ dev = [ "pytest-cov==7.1.0", "ruff==0.16.9", "twine==7.0.0", + "uvicorn==0.54.0", ] [tool.hatch.build.targets.sdist] -include = ["/LICENSE", "/README.md", "/src"] +include = ["/LICENSE", "/README.md", "/examples", "/src"] [tool.hatch.build.targets.wheel] packages = ["src/inflowpay"] @@ -56,11 +57,12 @@ packages = ["src/inflowpay"] [tool.mypy] python_version = "3.11" strict = true -files = ["src", "tests", "scripts"] +files = ["src", "tests", "scripts", "examples"] [tool.pytest.ini_options] addopts = [ "--cov=inflowpay", + "--cov=examples", "--cov-branch", "--cov-fail-under=100", "--cov-report=term-missing", @@ -70,10 +72,11 @@ addopts = [ "--strict-markers", ] asyncio_mode = "auto" +pythonpath = ["."] testpaths = ["tests"] [tool.coverage.run] -source = ["inflowpay"] +source = ["inflowpay", "examples"] branch = true [tool.coverage.report] diff --git a/scripts/verify_distribution.py b/scripts/verify_distribution.py index 51473e1..f918f25 100644 --- a/scripts/verify_distribution.py +++ b/scripts/verify_distribution.py @@ -1,5 +1,7 @@ """Build from source and verify the wheel outside the checkout.""" +import os +import shutil import subprocess import sys import tempfile @@ -155,6 +157,29 @@ def main() -> None: check=True, ) subprocess.run([str(python), "-I", "-c", OPTIONAL_CONSUMER], cwd=temporary, check=True) + subprocess.run( + ["uv", "pip", "install", "--python", str(python), "uvicorn==0.54.0"], check=True + ) + shutil.copytree( + repository / "examples", + temporary / "examples", + ignore=shutil.ignore_patterns("__pycache__"), + ) + settings = dict(os.environ) + for key in ("INFLOW_API_KEY", "MPP_SECRET_KEY", "INFLOW_BASE_URL", "TARGET_URL"): + settings.pop(key, None) + for name in ("mpp_buyer", "mpp_seller", "x402_buyer", "x402_seller"): + result = subprocess.run( + [str(python), "-I", str(temporary / "examples" / f"{name}.py")], + cwd=temporary, + env=settings, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 1, result + assert "INFLOW_API_KEY" in result.stderr, result.stderr + assert "Traceback" not in result.stderr, result.stderr if __name__ == "__main__": diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..8daa180 --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,234 @@ +import base64 +import json +import runpy +from collections.abc import Iterator +from copy import deepcopy +from typing import Any + +import httpx +import httpx._client +import pytest +import uvicorn +from examples import mpp_buyer, mpp_seller, x402_buyer, x402_seller +from fastapi import FastAPI +from x402.schemas import PaymentPayload, PaymentRequirements + +from inflowpay import ClientOptions +from inflowpay.mpp import encode +from test_mpp_seller import ID, PROBLEM +from test_mpp_seller import Platform as MppPlatform +from test_x402_seller import Platform as X402Platform + +MODULES = (mpp_buyer, mpp_seller, x402_buyer, x402_seller) + + +@pytest.fixture(autouse=True) +def example_environment(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + for name in ("INFLOW_API_KEY", "MPP_SECRET_KEY", "TARGET_URL", "INFLOW_BASE_URL"): + monkeypatch.delenv(name, raising=False) + yield + + +@pytest.mark.parametrize("module", MODULES) +def test_command_entry_requires_credentials( + module: Any, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit) as error: + runpy.run_path(module.__file__, run_name="__main__") + assert error.value.code == 1 + assert "INFLOW_API_KEY" in capsys.readouterr().err + + +@pytest.mark.parametrize("module", MODULES) +@pytest.mark.parametrize("cancel", [False, True]) +def test_main_success_and_interrupt( + module: Any, cancel: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + async def run() -> None: + if cancel: + raise KeyboardInterrupt + + monkeypatch.setattr(module, "run", run) + assert module.main() == (130 if cancel else 0) + + +@pytest.mark.parametrize( + "protocol,outcome", + [ + (protocol, outcome) + for protocol in ("mpp", "x402") + for outcome in ("paid", "free", "rejected", "missing", "malformed") + ] + + [("x402", "settle-failed"), ("x402", "legacy-receipt")], +) +async def test_example_pair( + protocol: str, outcome: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + platform = MppPlatform() if protocol == "mpp" else X402Platform() + if outcome == "rejected": + if isinstance(platform, MppPlatform): + platform.validation = {"success": False, "problem": PROBLEM} + else: + platform.verification = {"isValid": False, "invalidReason": "invalid_payload"} + created: list[dict[str, Any]] = [] + merchant_requests: list[httpx.Request] = [] + owned: list[httpx.AsyncBaseTransport] = [] + app_transport: httpx.ASGITransport + + class RoutingTransport(httpx.AsyncBaseTransport): + closed = False + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + if request.url.host == "platform.test": + assert request.headers["x-api-key"] == "secret" + path = request.url.path + if path == "/v1/transactions/x402-supported": + assert isinstance(platform, X402Platform) + return httpx.Response(200, json=platform.supported) + if path in ("/v1/transactions/mpp", "/v1/transactions/x402"): + value = json.loads(request.content) + created.append(value) + if protocol == "mpp": + credential = { + "challenge": value["challenge"], + "payload": {"transactionId": ID}, + } + return httpx.Response( + 200, json={"state": "ready", "credential": encode(credential)} + ) + return httpx.Response(200, json={"transactionId": ID, "approvalId": ID}) + if path == f"/v1/transactions/{ID}/x402": + payment = PaymentPayload( + accepted=PaymentRequirements.model_validate(created[0]["accept"]), + payload={"transactionId": ID}, + ).model_dump(by_alias=True, exclude_none=True) + return httpx.Response( + 200, + json={ + "encodedPayload": base64.b64encode( + json.dumps(payment).encode() + ).decode(), + "paymentPayload": payment, + }, + ) + return await platform.handle_async_request(request) + assert "x-api-key" not in request.headers + merchant_requests.append(request) + response = await app_transport.handle_async_request(request) + await response.aread() + paid = "payment-signature" in request.headers or "authorization" in request.headers + if paid: + header = "payment-receipt" if protocol == "mpp" else "payment-response" + if outcome == "rejected": + assert response.status_code == 402 + assert "widgets" not in response.text + if outcome == "missing": + response.headers.pop(header) + if outcome == "malformed": + response.headers[header] = "not-a-receipt" + if outcome == "legacy-receipt": + response.headers["x-payment-response"] = response.headers.pop(header) + if outcome == "settle-failed" and protocol == "x402": + response.headers[header] = base64.b64encode( + json.dumps( + { + "success": False, + "network": "inflow:1", + "transaction": "", + "errorReason": "settlement_failed", + } + ).encode() + ).decode() + return response + + async def aclose(self) -> None: + self.closed = True + + def transport(*args: object, **kwargs: object) -> RoutingTransport: + value = RoutingTransport() + owned.append(value) + return value + + monkeypatch.setattr(httpx, "AsyncHTTPTransport", transport) + monkeypatch.setattr(httpx._client, "AsyncHTTPTransport", transport) + monkeypatch.setenv("INFLOW_API_KEY", "secret") + monkeypatch.setenv("INFLOW_BASE_URL", "https://platform.test") + if outcome == "free": + monkeypatch.setenv("TARGET_URL", "http://merchant.test/free") + options = ClientOptions( + environment="sandbox", api_key="secret", base_url="https://platform.test" + ) + application = ( + mpp_seller.application(options, "test-secret") + if protocol == "mpp" + else x402_seller.application(options) + ) + async with application as app, httpx.ASGITransport(app) as app_transport: + buyer = mpp_buyer if protocol == "mpp" else x402_buyer + fails = outcome in ("rejected", "malformed") or ( + outcome == "settle-failed" and protocol == "x402" + ) + if fails: + with pytest.raises((httpx.HTTPStatusError, ValueError)): + await buyer.run() + else: + await buyer.run() + assert len(created) == (0 if outcome == "free" else 1) + assert len(merchant_requests) == (1 if outcome == "free" else 2) + assert all(isinstance(value, RoutingTransport) and value.closed for value in owned) + if outcome == "rejected": + assert not any( + request.url.path.endswith(("broadcast", "settle")) for request in platform.requests + ) + output = capsys.readouterr().out + assert "HTTP" in output and "secret" not in output + if outcome in ("free", "missing"): + assert "No seller receipt" in output + elif outcome == "paid": + assert "Seller receipt:" in output if protocol == "mpp" else "Seller settlement:" in output + + +@pytest.mark.parametrize("module,port", [(mpp_seller, 3000), (x402_seller, 3001)]) +async def test_seller_run_serves_actual_app( + module: Any, port: int, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("INFLOW_API_KEY", "secret") + monkeypatch.setenv("MPP_SECRET_KEY", "test-secret") + platform = MppPlatform() if module is mpp_seller else X402Platform() + monkeypatch.setattr(httpx, "AsyncHTTPTransport", lambda **kwargs: platform) + monkeypatch.setattr(httpx._client, "AsyncHTTPTransport", lambda **kwargs: platform) + called = [] + + async def serve(server: uvicorn.Server, sockets: object = None) -> None: + called.append(server.config.port) + assert server.config.host == "127.0.0.1" + assert isinstance(server.config.app, FastAPI) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(server.config.app), base_url="http://localhost" + ) as http: + assert (await http.get("/free")).json() == {"ok": True} + assert (await http.get("/api/widgets")).status_code == 402 + + monkeypatch.setattr(uvicorn.Server, "serve", serve) + await module.run() + assert called == [port] + assert platform.closed + + +async def test_x402_seller_rejects_empty_offers(monkeypatch: pytest.MonkeyPatch) -> None: + platform = X402Platform() + platform.config = deepcopy(platform.config) + platform.config["assets"] = [] + platform.config["paymentMethods"] = [] + monkeypatch.setattr(httpx, "AsyncHTTPTransport", lambda **kwargs: platform) + monkeypatch.setattr(httpx._client, "AsyncHTTPTransport", lambda **kwargs: platform) + with pytest.raises(ValueError, match="no USDC"): + async with x402_seller.application(ClientOptions(api_key="secret")): + pytest.fail("empty offers must not start a server") + assert platform.closed + + +async def test_mpp_seller_requires_challenge_secret(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("INFLOW_API_KEY", "secret") + with pytest.raises(ValueError, match="MPP_SECRET_KEY"): + await mpp_seller.run() diff --git a/uv.lock b/uv.lock index 4fad6d6..890ed29 100644 --- a/uv.lock +++ b/uv.lock @@ -1485,6 +1485,7 @@ dev = [ { name = "pytest-cov" }, { name = "ruff" }, { name = "twine" }, + { name = "uvicorn" }, ] [package.metadata] @@ -1511,6 +1512,7 @@ dev = [ { name = "pytest-cov", specifier = "==7.1.0" }, { name = "ruff", specifier = "==0.16.9" }, { name = "twine", specifier = "==7.0.0" }, + { name = "uvicorn", specifier = "==0.54.0" }, ] [[package]] @@ -3145,8 +3147,8 @@ name = "uvicorn" version = "0.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, - { name = "h11", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, + { name = "click" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } wheels = [