diff --git a/examples/README.md b/examples/README.md index d93a03f..f0e89ab 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,6 +43,12 @@ export MPP_SECRET_KEY="$(uv run python -c 'import secrets; print(secrets.token_h uv run --locked python -m examples.mpp_seller ``` +The Seller's request callback selects its own price on every call: **0.01 USDC** +for `/api/widgets` and **0.05 USDC** for `/api/widgets?bundle=large`. The same +request-building helpers accept fixed dictionaries. For Stripe and CARD, the +callback selects **USD 1.25** or **USD 2.50**; the helpers convert dollars to cents +and attach the configured payment details. + The server listens at `http://127.0.0.1:3000`. Check both routes without paying: ```sh @@ -138,7 +144,12 @@ 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. +header describing the available offers. Add `?bundle=large` to request **0.05 USDC** +instead. The Seller selects the price from its own price list on each request. +The callback can also return an amount and currency, such as +`{"amount": "0.05", "currency": "USDT"}`. Use `seller.resource_server(facilitator)` +with request-time `seller.offers(...)` or `seller.route(...)` prices; it registers +the InFlow schemes and keeps only the options matching the resolved price. Run the [Buyer](x402_buyer.py) in a second terminal: diff --git a/examples/mpp_seller.py b/examples/mpp_seller.py index 84632f6..e8ea07d 100644 --- a/examples/mpp_seller.py +++ b/examples/mpp_seller.py @@ -11,6 +11,7 @@ from starlette.responses import JSONResponse from inflowpay import ClientOptions +from inflowpay.mpp import WireObject from inflowpay.mpp.seller import Seller @@ -20,13 +21,20 @@ async def application( ) -> AsyncIterator[FastAPI]: # Keep the Seller open for the whole server lifetime, not just startup. async with await Seller.create(options, method=method) as seller: + + def price(request: Request) -> WireObject: + large = request.query_params.get("bundle") == "large" + if method in ("stripe", "card"): + return {"amount": "2.50" if large else "1.25"} + return {"amount": "0.05" if large else "0.01", "currency": "USDC"} + # Stripe and CARD prices are dollars here; their challenges contain integer cents. if method == "card": - terms = seller.card_request({"amount": "1.25"}) + terms = seller.card_request(price) elif method == "stripe": - terms = seller.stripe_request({"amount": "1.25"}) + terms = seller.stripe_request(price) else: - terms = seller.charge_request({"amount": "0.01", "currency": "USDC"}) + terms = seller.charge_request(price) app = FastAPI() @app.get("/api/widgets") @@ -67,7 +75,8 @@ async def run() -> None: async with application(options, secret, method=method) as app: price = "0.01 USDC" if method == "inflow" else f"1.25 USD via {method}" print( - f"MPP: http://127.0.0.1:3000/api/widgets costs {price}; /free requires no payment.", + f"MPP: http://127.0.0.1:3000/api/widgets starts at {price}; " + "?bundle=large selects the larger bundle; /free requires no payment.", flush=True, ) await uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=3000)).serve() diff --git a/examples/x402_seller.py b/examples/x402_seller.py index a649194..e4c5060 100644 --- a/examples/x402_seller.py +++ b/examples/x402_seller.py @@ -7,9 +7,8 @@ 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 x402.http.types import HTTPRequestContext, RouteConfig from inflowpay import ClientOptions from inflowpay.x402.facilitator import Facilitator @@ -28,16 +27,19 @@ async def application( 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"]) + resource = await seller.resource_server(facilitator) + + def price(context: HTTPRequestContext) -> str: + large = context.adapter.get_query_param("bundle") == "large" + if instrument: + return "$2.50" if large else "$1.25" + return "0.05 USDC" if large else "0.01 USDC" + offers = await seller.offers( - "$1.25" if instrument else "0.01 USDC", + price, schemes=["instrument"] if instrument else ["balance", "exact"], payment_flow=payment_flow, ) - if not offers: - raise ValueError("The Seller configuration has no matching payment offers.") app = FastAPI() app.middleware("http")( payment_middleware({"GET /api/widgets": RouteConfig(accepts=offers)}, resource) @@ -74,9 +76,10 @@ async def run() -> None: instrument=scheme == "instrument", payment_flow="upfront" if payment_flow == "upfront" else "authorization", ) as app: - price = "1.25 USD via instrument" if scheme == "instrument" else "0.01 USDC" + description = "1.25 USD via instrument" if scheme == "instrument" else "0.01 USDC" print( - f"x402: http://127.0.0.1:3001/api/widgets costs {price}; /free requires no payment.", + f"x402: http://127.0.0.1:3001/api/widgets starts at {description}; " + "?bundle=large selects the larger bundle; /free requires no payment.", flush=True, ) await uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=3001)).serve() diff --git a/src/inflowpay/mpp/seller.py b/src/inflowpay/mpp/seller.py index 0952813..e58ef84 100644 --- a/src/inflowpay/mpp/seller.py +++ b/src/inflowpay/mpp/seller.py @@ -1,10 +1,11 @@ from __future__ import annotations import re +from collections.abc import Callable from copy import deepcopy from dataclasses import dataclass, field from types import TracebackType -from typing import Self, cast +from typing import Self, TypeVar, cast, overload from uuid import uuid4 from mpp import Credential, Receipt @@ -30,6 +31,8 @@ __all__ = ["MppCredentialProblemError", "MppSellerConfigurationError", "Seller"] +_Request = TypeVar("_Request") + class MppSellerConfigurationError(ValueError): pass @@ -218,11 +221,23 @@ async def __aexit__( async def aclose(self) -> None: await self._client.aclose() - def charge_request(self, request: WireObject) -> WireObject: + @overload + def charge_request(self, request: WireObject) -> WireObject: ... + + @overload + def charge_request( + self, request: Callable[[_Request], WireObject] + ) -> Callable[[_Request], WireObject]: ... + + def charge_request( + self, request: WireObject | Callable[[_Request], WireObject] + ) -> WireObject | Callable[[_Request], WireObject]: if self.method in ("stripe", "card"): raise MppSellerConfigurationError( f"Use {self.method}_request with a decimal USD amount" ) + if callable(request): + return lambda context: self.charge_request(request(context)) result = deepcopy(request) # Use standalone pympp.pay: its high-level routes convert all prices to token units. if "decimals" in result: @@ -241,9 +256,21 @@ def charge_request(self, request: WireObject) -> WireObject: } return validate_request(self.method, "charge", result) - def stripe_request(self, request: WireObject) -> WireObject: + @overload + def stripe_request(self, request: WireObject) -> WireObject: ... + + @overload + def stripe_request( + self, request: Callable[[_Request], WireObject] + ) -> Callable[[_Request], WireObject]: ... + + def stripe_request( + self, request: WireObject | Callable[[_Request], WireObject] + ) -> WireObject | Callable[[_Request], WireObject]: if self.method != "stripe": raise MppSellerConfigurationError("stripe_request requires a Stripe Seller") + if callable(request): + return lambda context: self.stripe_request(request(context)) cents = _dollar_cents(request.get("amount"), "Stripe") details = _stripe_profile(self._config) if "metadata" in request: @@ -283,9 +310,21 @@ def stripe_request(self, request: WireObject) -> WireObject: result[key] = value return result - def card_request(self, request: WireObject) -> WireObject: + @overload + def card_request(self, request: WireObject) -> WireObject: ... + + @overload + def card_request( + self, request: Callable[[_Request], WireObject] + ) -> Callable[[_Request], WireObject]: ... + + def card_request( + self, request: WireObject | Callable[[_Request], WireObject] + ) -> WireObject | Callable[[_Request], WireObject]: if self.method != "card": raise MppSellerConfigurationError("card_request requires a CARD Seller") + if callable(request): + return lambda context: self.card_request(request(context)) result = _card_profile(self._config) result["amount"] = _dollar_cents(request.get("amount"), "CARD") details = object_value(result["methodDetails"]) diff --git a/src/inflowpay/x402/_pricing.py b/src/inflowpay/x402/_pricing.py new file mode 100644 index 0000000..504141b --- /dev/null +++ b/src/inflowpay/x402/_pricing.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import inspect +import re +from collections.abc import Awaitable, Callable, Sequence +from copy import deepcopy +from dataclasses import replace +from typing import Any, Literal, NotRequired, TypedDict +from weakref import finalize + +from pydantic import PrivateAttr +from x402 import x402ResourceServer +from x402.http.types import HTTPRequestContext, PaymentOption +from x402.schemas import ( + AssetAmount, + PaymentPayload, + PaymentRequired, + PaymentRequirements, + Price, + ResourceConfig, + ResourceInfo, +) +from x402.schemas.helpers import parse_money + +from ._seller import SellerConfig, build_offers + + +class PriceSpec(TypedDict): + amount: str + currency: NotRequired[str] + + +DynamicPrice = Callable[[HTTPRequestContext], Price | PriceSpec | Awaitable[Price | PriceSpec]] + + +class _ResolvedPrice(AssetAmount): + # The HTTP adapter passes prices through ResourceConfig. Keep resolved + # offers private so they cannot become part of a payment's wire format. + _offers: list[PaymentOption] = PrivateAttr(default_factory=list) + + +class _DynamicExtensions(dict[str, Any]): + def __init__(self, select: Callable[[list[PaymentRequirements]], dict[str, Any]]) -> None: + super().__init__() + self._select = select + + def __bool__(self) -> bool: + # The upstream HTTP adapter enriches only truthy declarations. + return True + + def for_request(self, declarations: dict[str, Any]) -> _DynamicExtensions: + result = _RequestExtensions(self._select) + result.update(declarations) + return result + + def resolve(self, requirements: list[PaymentRequirements]) -> dict[str, Any]: + return {**self, **self._select(requirements)} + + +class _RequestExtensions(_DynamicExtensions): + def resolve(self, requirements: list[PaymentRequirements]) -> dict[str, Any]: + # Upstream retains this request-local map for verification and settlement. + self.update(super().resolve(requirements)) + return dict(self) + + +class _ResourceServer(x402ResourceServer): + def enrich_extensions(self, declared: dict[str, Any], transport_context: Any) -> dict[str, Any]: + enriched = super().enrich_extensions(declared, transport_context) + return ( + declared.for_request(enriched) if isinstance(declared, _DynamicExtensions) else enriched + ) + + # The public async method conflicts with upstream's synchronous base signature. + async def _build_payment_required_response_async( + self, + requirements: list[PaymentRequirements], + resource: ResourceInfo | None = None, + error: str | None = None, + extensions: dict[str, Any] | None = None, + transport_context: Any = None, + payment_payload: PaymentPayload | None = None, + ) -> PaymentRequired: + return await super()._build_payment_required_response_async( + requirements, + resource, + error, + extensions.resolve(requirements) + if isinstance(extensions, _DynamicExtensions) + else extensions, + transport_context, + payment_payload, + ) + + def build_payment_requirements( + self, config: ResourceConfig, extensions: list[str] | None = None + ) -> list[PaymentRequirements]: + if not isinstance(config.price, _ResolvedPrice): + return super().build_payment_requirements(config, extensions) + requirements: list[PaymentRequirements] = [] + for offer in config.price._offers: + assert isinstance(offer.price, AssetAmount) and isinstance(offer.pay_to, str) + requirements.extend( + super().build_payment_requirements( + ResourceConfig( + scheme=offer.scheme, + network=offer.network, + pay_to=offer.pay_to, + price=offer.price, + max_timeout_seconds=offer.max_timeout_seconds, + extra=offer.extra, + ), + extensions, + ) + ) + return requirements + + +def dynamic_offers( + config: SellerConfig, + price: DynamicPrice, + *, + currency: str | None, + schemes: Sequence[str] | None, + networks: Sequence[str] | None, + max_timeout_seconds: int, + permit2: bool = False, + payment_flow: Literal["authorization", "upfront"], +) -> list[PaymentOption]: + snapshot = config.model_copy(deep=True) + schemes, networks = deepcopy(schemes), deepcopy(networks) + + def build(amount: str, selected: str | None) -> list[PaymentOption]: + return build_offers( + snapshot, + amount, + currency=selected, + schemes=schemes, + networks=networks, + max_timeout_seconds=max_timeout_seconds, + permit2=permit2, + payment_flow=payment_flow, + ) + + candidates = build("$1", None) + _require_offers(candidates) + + async def resolve(context: HTTPRequestContext) -> list[PaymentOption]: + value = price(context) + resolved = await value if inspect.isawaitable(value) else value + if isinstance(resolved, AssetAmount): + offers = [] + for candidate in candidates: + assert isinstance(candidate.price, AssetAmount) + left, right = candidate.price.asset, resolved.asset + if candidate.network.startswith("eip155:"): + left, right = left.lower(), right.lower() + if left != right: + continue + if candidate.scheme == "instrument": + scale = int(candidate.price.amount) + if not re.fullmatch(r"[0-9]+", resolved.amount): + raise ValueError("Instrument amount must be integer atomic units") + cents = int(resolved.amount) * 100 + if cents % scale or not 50 <= cents // scale <= 9223372036854775807: + raise ValueError( + "Instrument payments require USD 0.50-92233720368547758.07" + ) + offers.append( + replace( + candidate, + price=AssetAmount(asset=resolved.asset, amount=resolved.amount), + extra=deepcopy({**(candidate.extra or {}), **(resolved.extra or {})}), + ) + ) + else: + if isinstance(resolved, dict): + amount, selected = resolved["amount"], resolved.get("currency", currency) + else: + money = parse_money(resolved) + amount, selected = money["amount"], currency or money.get("symbol") or "USD" + offers = build(amount, selected) + _require_offers(offers) + return deepcopy(offers) + + resolved_by_request: dict[int, list[PaymentOption]] = {} + occurrences: dict[tuple[str, str, str, str], int] = {} + + def option_price( + candidate: PaymentOption, + ) -> Callable[[HTTPRequestContext], Awaitable[AssetAmount]]: + key = _option_key(candidate) + occurrence = occurrences.get(key, 0) + occurrences[key] = occurrence + 1 + + async def select(context: HTTPRequestContext) -> AssetAmount: + request_id = id(context) + if request_id not in resolved_by_request: + resolved_by_request[request_id] = await resolve(context) + # Do not retain requests or their prices beyond the request's lifetime. + finalize(context, resolved_by_request.pop, request_id, None) + result = _ResolvedPrice(asset="", amount="0") + result._offers = [ + offer for offer in resolved_by_request[request_id] if _option_key(offer) == key + ][occurrence : occurrence + 1] + return result + + return select + + return [replace(candidate, price=option_price(candidate)) for candidate in candidates] + + +def _option_key(offer: PaymentOption) -> tuple[str, str, str, str]: + assert isinstance(offer.price, AssetAmount) and isinstance(offer.pay_to, str) + asset = offer.price.asset + return ( + offer.scheme, + offer.network, + offer.pay_to, + asset.lower() if offer.network.startswith("eip155:") else asset, + ) + + +def _require_offers(offers: list[PaymentOption]) -> None: + if not offers: + raise ValueError("No payment options match the requested price and filters") diff --git a/src/inflowpay/x402/_seller.py b/src/inflowpay/x402/_seller.py index e02553b..5705fd0 100644 --- a/src/inflowpay/x402/_seller.py +++ b/src/inflowpay/x402/_seller.py @@ -3,13 +3,16 @@ import re from collections.abc import Sequence from copy import deepcopy -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal from pydantic import Field from x402.http.types import PaymentOption from x402.schemas import AssetAmount, SupportedKind from x402.schemas.base import BaseX402Model +if TYPE_CHECKING: + from ._pricing import DynamicPrice + PERMIT2_PROXY = "0x402085c248EeA27D92E8b30b2C58ed07f9E20001" @@ -94,7 +97,7 @@ def _atomic(integer: str, fraction: str, decimals: int) -> str: def build_offers( config: SellerConfig, - price: str, + price: str | DynamicPrice, *, currency: str | None = None, schemes: Sequence[str] | None = None, @@ -103,6 +106,19 @@ def build_offers( permit2: bool = False, payment_flow: Literal["authorization", "upfront"] = "authorization", ) -> list[PaymentOption]: + if callable(price): + from ._pricing import dynamic_offers + + return dynamic_offers( + config, + price, + currency=currency, + schemes=schemes, + networks=networks, + max_timeout_seconds=max_timeout_seconds, + permit2=permit2, + payment_flow=payment_flow, + ) if payment_flow not in ("authorization", "upfront"): raise ValueError("payment_flow must be authorization or upfront") integer, fraction, selected = _price(price, currency) diff --git a/src/inflowpay/x402/seller.py b/src/inflowpay/x402/seller.py index 7c9f2cb..167b0d2 100644 --- a/src/inflowpay/x402/seller.py +++ b/src/inflowpay/x402/seller.py @@ -7,13 +7,16 @@ from typing import Generic, Literal, Self, TypedDict, TypeVar, cast from pydantic import BaseModel +from x402 import x402ResourceServer from x402.http.types import PaymentOption, RouteConfig from x402.interfaces import PaymentFlowConfig, SchemeNetworkServer from x402.schemas import AssetAmount, PaymentRequirements, Price, SupportedKind, SupportedResponse +from x402.server_base import FacilitatorClient from .._runtime import Client from ..options import ClientOptions from ._core import INFLOW_EIP7702_GAS_SPONSORING, declare_sponsorship +from ._pricing import DynamicPrice, PriceSpec, _DynamicExtensions, _ResolvedPrice, _ResourceServer from ._seller import ( PERMIT2_PROXY, SellerConfig, @@ -23,7 +26,7 @@ upto_kind, ) -__all__ = ["SchemeRegistration", "Seller", "SellerConfig"] +__all__ = ["DynamicPrice", "PriceSpec", "SchemeRegistration", "Seller", "SellerConfig"] _T = TypeVar("_T", bound=BaseModel) @@ -84,6 +87,8 @@ def __init__(self, scheme: str, network: str, methods: list[str]) -> None: } def parse_price(self, price: Price, network: str) -> AssetAmount: + if isinstance(price, _ResolvedPrice): + raise ValueError("Request-time Seller prices require Seller.resource_server") if not isinstance(price, AssetAmount): raise ValueError("Use Seller.offers to provide a price in atomic asset units") return price.model_copy(deep=True) @@ -154,7 +159,7 @@ async def get_signer_addresses(self, network: str) -> list[str]: async def offers( self, - price: str, + price: str | DynamicPrice, *, currency: str | None = None, schemes: Sequence[str] | None = None, @@ -174,7 +179,7 @@ async def offers( async def route( self, - price: str, + price: str | DynamicPrice, *, currency: str | None = None, schemes: Sequence[str] | None = None, @@ -194,51 +199,29 @@ async def route( payment_flow=payment_flow, ) route = RouteConfig(accepts=offers) - candidates = [ - offer for offer in offers if (offer.extra or {}).get("assetTransferMethod") == "permit2" - ] - if not candidates or not all( - offer.scheme == "exact" - and offer.network.startswith("eip155:") - and str((offer.extra or {}).get("permit2Proxy", "")).lower() == PERMIT2_PROXY.lower() - for offer in candidates - ): + if callable(price): + supported = await self.get_supported(refresh=True) + route.extensions = _DynamicExtensions( + lambda requirements: _sponsorship(requirements, supported) + ) return route - eip2612 = all( - (offer.extra or {}).get("supportsEip2612") is True - and isinstance((offer.extra or {}).get("name"), str) - and (offer.extra or {})["name"] - and isinstance((offer.extra or {}).get("version"), str) - and (offer.extra or {})["version"] - for offer in candidates - ) - eip7702 = all((offer.extra or {}).get("supportsEip7702") is True for offer in candidates) - if not eip2612 and not eip7702: + if not _sponsorship(offers): return route - supported = await self.get_supported(refresh=True) - - def supports(require_eip7702: bool) -> bool: - return all( - any( - kind.x402_version == 2 - and kind.scheme == offer.scheme - and kind.network == offer.network - and (not require_eip7702 or (kind.extra or {}).get("supportsEip7702") is True) - for kind in supported.kinds - ) - for offer in candidates - ) - - if eip2612 and "eip2612GasSponsoring" in supported.extensions and supports(False): - from x402.extensions.eip2612_gas_sponsoring import ( - declare_eip2612_gas_sponsoring_extension, - ) - - route.extensions = deepcopy(declare_eip2612_gas_sponsoring_extension()) - elif eip7702 and INFLOW_EIP7702_GAS_SPONSORING in supported.extensions and supports(True): - route.extensions = declare_sponsorship() + route.extensions = _sponsorship(offers, await self.get_supported(refresh=True)) or None return route + async def resource_server( + self, + facilitator: FacilitatorClient | list[FacilitatorClient], + *, + schemes: Sequence[str] | None = None, + ) -> x402ResourceServer: + """Register InFlow schemes and resolve request-time offers for upstream middleware.""" + server = _ResourceServer(facilitator) + for registration in await self.scheme_registrations(schemes=schemes): + server.register(registration["network"], registration["server"]) + return server + async def scheme_registrations( self, *, schemes: Sequence[str] | None = None ) -> list[SchemeRegistration]: @@ -274,3 +257,58 @@ def add(scheme: str, network: str, method: object) -> None: server = _Passthrough(scheme, network, methods) result.append({"network": network, "server": server}) return result + + +def _sponsorship( + offers: Sequence[PaymentOption | PaymentRequirements], + supported: SupportedResponse | None = None, +) -> dict[str, object]: + candidates = [ + offer for offer in offers if (offer.extra or {}).get("assetTransferMethod") == "permit2" + ] + if not candidates or not all( + offer.scheme == "exact" + and offer.network.startswith("eip155:") + and str((offer.extra or {}).get("permit2Proxy", "")).lower() == PERMIT2_PROXY.lower() + for offer in candidates + ): + return {} + eip2612 = all( + (offer.extra or {}).get("supportsEip2612") is True + and isinstance((offer.extra or {}).get("name"), str) + and (offer.extra or {})["name"] + and isinstance((offer.extra or {}).get("version"), str) + and (offer.extra or {})["version"] + for offer in candidates + ) + eip7702 = all((offer.extra or {}).get("supportsEip7702") is True for offer in candidates) + if not eip2612 and not eip7702: + return {} + + def supports(require_eip7702: bool) -> bool: + return supported is None or all( + any( + kind.x402_version == 2 + and kind.scheme == offer.scheme + and kind.network == offer.network + and (not require_eip7702 or (kind.extra or {}).get("supportsEip7702") is True) + for kind in supported.kinds + ) + for offer in candidates + ) + + if ( + eip2612 + and (supported is None or "eip2612GasSponsoring" in supported.extensions) + and supports(False) + ): + from x402.extensions.eip2612_gas_sponsoring import declare_eip2612_gas_sponsoring_extension + + return deepcopy(declare_eip2612_gas_sponsoring_extension()) + if ( + eip7702 + and (supported is None or INFLOW_EIP7702_GAS_SPONSORING in supported.extensions) + and supports(True) + ): + return declare_sponsorship() + return {} diff --git a/tests/test_examples.py b/tests/test_examples.py index 4ec09c4..02a68e6 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -301,7 +301,7 @@ async def test_x402_seller_rejects_empty_offers(monkeypatch: pytest.MonkeyPatch) platform.config["paymentMethods"] = [] monkeypatch.setattr(httpx, "AsyncHTTPTransport", lambda **kwargs: platform) monkeypatch.setattr(httpx._client, "AsyncHTTPTransport", lambda **kwargs: platform) - with pytest.raises(ValueError, match="no matching"): + with pytest.raises(ValueError, match="No payment options match"): async with x402_seller.application(ClientOptions(api_key="secret")): pytest.fail("empty offers must not start a server") assert platform.closed diff --git a/tests/test_request_pricing.py b/tests/test_request_pricing.py new file mode 100644 index 0000000..d54c5cc --- /dev/null +++ b/tests/test_request_pricing.py @@ -0,0 +1,750 @@ +import asyncio +import gc +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from copy import deepcopy +from typing import Literal +from weakref import ReferenceType, ref + +import httpx +import pytest +from fastapi import FastAPI, Request +from mpp import Challenge, Credential, Receipt +from mpp.server.decorator import pay +from starlette.responses import JSONResponse +from x402 import x402ResourceServer +from x402.http.middleware.fastapi import payment_middleware +from x402.http.types import HTTPRequestContext, RouteConfig +from x402.http.utils import decode_payment_required_header, encode_payment_signature_header +from x402.schemas import AssetAmount, PaymentPayload, PaymentRequired, Price +from x402.schemas.hooks import SettleContext, VerifyContext + +from inflowpay.mpp import WireObject +from inflowpay.mpp.seller import Seller as MppSeller +from inflowpay.x402.facilitator import Facilitator +from inflowpay.x402.seller import DynamicPrice, PriceSpec, Seller +from test_card_seller import platform as card_platform +from test_instrument_parity import card_config +from test_mpp_seller import ID, Platform, options +from test_stripe_seller import platform as stripe_platform +from test_x402_seller import CASES +from test_x402_upfront import FlowPlatform + + +@asynccontextmanager +async def merchant( + platform: FlowPlatform, + price: str | DynamicPrice, + *, + flow: Literal["authorization", "upfront"] = "authorization", + schemes: list[str] | None = None, + plain: bool = False, + status: int = 200, + currency: str | None = None, +) -> AsyncIterator[httpx.AsyncClient]: + async with ( + await Seller.create(platform.options()) as seller, + await Facilitator.create(platform.options()) as facilitator, + ): + if plain: + resource = x402ResourceServer(facilitator) + for registration in await seller.scheme_registrations(): + resource.register(registration["network"], registration["server"]) + else: + resource = await seller.resource_server(facilitator) + route = await seller.route(price, schemes=schemes, payment_flow=flow, currency=currency) + # A normal upstream option must continue to coexist with InFlow callbacks. + fixed = await seller.offers("3 USDC", schemes=["balance"]) + app = FastAPI() + app.middleware("http")( + payment_middleware({"GET /paid": route, "GET /fixed": RouteConfig(fixed)}, resource) + ) + + @app.get("/paid") + async def paid() -> JSONResponse: + platform.events.append("handler") + return JSONResponse({"paid": True}, status_code=status) + + @app.get("/free") + async def free() -> dict[str, bool]: + return {"free": True} + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app, raise_app_exceptions=False), + base_url="https://seller.example", + ) as client: + yield client + + +@pytest.mark.parametrize("flow", ["authorization", "upfront"]) +@pytest.mark.parametrize("scheme", ["exact", "balance", "instrument"]) +@pytest.mark.parametrize("outcome", ["success", "rejected", "handler-error"]) +async def test_dynamic_x402_execution( + flow: Literal["authorization", "upfront"], scheme: str, outcome: str +) -> None: + platform = FlowPlatform("rejected" if outcome == "rejected" else "success") + if scheme == "instrument": + platform.config = card_config().model_dump(by_alias=True) + platform.supported = { + "kinds": [{"scheme": scheme, "network": "inflow:1", "x402Version": 2}] + } + calls: list[str] = [] + + def price(context: HTTPRequestContext) -> PriceSpec: + calls.append(context.adapter.get_url()) + return {"amount": "1.25", "currency": "USD" if scheme == "instrument" else "USDC"} + + async with merchant( + platform, + price, + schemes=[scheme], + flow=flow, + status=500 if outcome == "handler-error" else 200, + ) as client: + assert not calls + assert (await client.get("/free")).status_code == 200 + assert not calls + response = await client.get("/paid") + assert response.status_code == 402 + required = decode_payment_required_header(response.headers["payment-required"]) + assert isinstance(required, PaymentRequired) + assert len(required.accepts) == 1 + accepted = required.accepts[0] + assert accepted.amount == str( + 125 * 10 ** ({"exact": 6, "balance": 8, "instrument": 18}[scheme] - 2) + ) + assert len(calls) == 1 + payment = PaymentPayload(accepted=accepted, payload={"transactionId": ID}) + response = await client.get( + "/paid", headers={"payment-signature": encode_payment_signature_header(payment)} + ) + assert len(calls) == 2 + if flow == "upfront": + assert platform.events == ( + ["settle"] if outcome == "rejected" else ["settle", "handler"] + ) + else: + assert platform.events == ( + ["verify", "handler"] + if outcome == "handler-error" + else ["verify", "handler", "settle"] + ) + assert response.status_code == ( + 402 if outcome == "rejected" else 500 if outcome == "handler-error" else 200 + ) + + +@pytest.mark.parametrize( + "value", + [ + "0.25 USDC", + 0.25, + {"amount": "0.25", "currency": "USDC"}, + AssetAmount(asset="USDC", amount="25000000", extra={"custom": {"keep": True}}), + ], +) +async def test_dynamic_x402_price_forms(value: Price | PriceSpec) -> None: + platform = FlowPlatform() + before = deepcopy(value) + calls = 0 + + async def price(context: HTTPRequestContext) -> Price | PriceSpec: + nonlocal calls + calls += 1 + await asyncio.sleep(0) + return value + + async with merchant(platform, price, schemes=["balance"]) as client: + for _ in range(2): + response = await client.get("/paid") + assert response.status_code == 402 + required = decode_payment_required_header(response.headers["payment-required"]) + assert isinstance(required, PaymentRequired) + assert required.accepts[0].amount == "25000000" + assert calls == 2 + assert value == before + assert (await client.get("/fixed")).status_code == 402 + assert calls == 2 + + +async def test_currency_changes_are_request_local() -> None: + platform = FlowPlatform() + platform.config["assets"].append( + { + **platform.config["assets"][0], + "assetId": "0xabcdef", + "currency": "USDT", + "assetName": "USDT", + } + ) + entered = asyncio.Event() + calls = 0 + + async def price(context: HTTPRequestContext) -> PriceSpec: + nonlocal calls + calls += 1 + if calls == 2: + entered.set() + await entered.wait() + currency = context.adapter.get_query_param("currency") or "USDC" + assert isinstance(currency, str) + return {"amount": "1" if currency == "USDC" else "2", "currency": currency} + + async with merchant(platform, price) as client: + responses = await asyncio.gather( + client.get("/paid?currency=USDC"), client.get("/paid?currency=USDT") + ) + for response, currency, amount in zip(responses, ("USDC", "USDT"), ("1", "2"), strict=True): + assert response.status_code == 402 + required = decode_payment_required_header(response.headers["payment-required"]) + assert isinstance(required, PaymentRequired) + offers = required.accepts + assert len(offers) == 2 + assert {offer.extra["assetName"] for offer in offers} == {currency} + assert offers[0].amount == amount + "000000" + assert offers[1].amount == amount + "00000000" + assert calls == 2 + assert not platform.events + + +@pytest.mark.parametrize("flow", ["authorization", "upfront"]) +@pytest.mark.parametrize("change", ["amount", "currency"]) +async def test_x402_paid_retry_must_match_current_price( + flow: Literal["authorization", "upfront"], change: str +) -> None: + platform = FlowPlatform() + platform.config["assets"].append( + {**platform.config["assets"][0], "assetId": "0xabcdef", "currency": "USDT"} + ) + current: PriceSpec = {"amount": "1", "currency": "USDC"} + async with merchant(platform, lambda context: current, flow=flow) as client: + response = await client.get("/paid") + required = decode_payment_required_header(response.headers["payment-required"]) + assert isinstance(required, PaymentRequired) + payment = PaymentPayload(accepted=required.accepts[0], payload={"transactionId": ID}) + headers = {"payment-signature": encode_payment_signature_header(payment)} + if change == "amount": + current["amount"] = "2" + else: + current["currency"] = "USDT" + assert (await client.get("/paid", headers=headers)).status_code == 402 + assert not platform.events + current.update({"amount": "1", "currency": "USDC"}) + assert (await client.get("/paid", headers=headers)).status_code == 200 + assert platform.events == ( + ["settle", "handler"] if flow == "upfront" else ["verify", "handler", "settle"] + ) + + +async def test_callback_can_override_default_currency_for_instrument() -> None: + platform = FlowPlatform() + platform.config = card_config().model_dump(by_alias=True) + platform.supported = { + "kinds": [{"scheme": "instrument", "network": "inflow:1", "x402Version": 2}] + } + + def price(context: HTTPRequestContext) -> PriceSpec: + return {"amount": "1.25", "currency": "USD"} + + async with merchant(platform, price, schemes=["instrument"], currency="USDC") as client: + response = await client.get("/paid") + assert response.status_code == 402 + required = decode_payment_required_header(response.headers["payment-required"]) + assert isinstance(required, PaymentRequired) + assert required.accepts[0].asset == "USD" + assert required.accepts[0].amount == "1250000000000000000" + + +@pytest.mark.parametrize("mode", ["plain", "unsupported", "invalid", "raises"]) +async def test_bad_dynamic_prices_never_charge(mode: str) -> None: + platform = FlowPlatform() + + def price(context: HTTPRequestContext) -> str: + if mode == "raises": + raise RuntimeError("Price unavailable") + return "1 UNKNOWN" if mode == "unsupported" else "invalid" if mode == "invalid" else "$1" + + async with merchant(platform, price, schemes=["exact"], plain=mode == "plain") as client: + assert (await client.get("/paid")).status_code == 500 + assert not platform.events + + +@pytest.mark.parametrize( + "asset,amount,valid", + [ + ("USD", "500000000000000000", True), + ("USD", "490000000000000000", False), + ("USD", "1000000000000000001", False), + ("USD", "92233720368547758080000000000000000", False), + ("USD", "invalid", False), + ("EUR", "500000000000000000", False), + ], +) +async def test_dynamic_atomic_instrument_prices(asset: str, amount: str, valid: bool) -> None: + platform = FlowPlatform() + platform.config = card_config().model_dump(by_alias=True) + platform.supported = { + "kinds": [{"scheme": "instrument", "network": "inflow:1", "x402Version": 2}] + } + value = AssetAmount(asset=asset, amount=amount) + async with merchant(platform, lambda context: value, schemes=["instrument"]) as client: + response = await client.get("/paid") + assert response.status_code == (402 if valid else 500) + if valid: + required = decode_payment_required_header(response.headers["payment-required"]) + assert isinstance(required, PaymentRequired) + assert required.accepts[0].amount == amount + assert not platform.events + + +@pytest.mark.parametrize( + "network,asset", [("eip155:8453", "0xAbCdEf"), ("solana:test", "MintAbCd")] +) +@pytest.mark.parametrize("matching", [True, False]) +async def test_atomic_asset_matching(network: str, asset: str, matching: bool) -> None: + platform = FlowPlatform() + platform.config["paymentMethods"] = [] + platform.config["assets"][0].update({"network": network, "assetId": asset}) + platform.supported = {"kinds": [{"scheme": "exact", "network": network, "x402Version": 2}]} + selected = asset.upper() if network.startswith("eip155:") else asset + if not matching: + selected = "other" if network.startswith("eip155:") else asset.swapcase() + value = AssetAmount(asset=selected, amount="250000", extra={"custom": {"keep": True}}) + before = value.model_copy(deep=True) + async with merchant(platform, lambda context: value, schemes=["exact"]) as client: + response = await client.get("/paid") + assert response.status_code == (402 if matching else 500) + if matching: + required = decode_payment_required_header(response.headers["payment-required"]) + assert isinstance(required, PaymentRequired) + assert required.accepts[0].extra["custom"] == {"keep": True} + assert required.accepts[0].asset == selected + assert value == before + + +async def test_cancelled_price_resolution_does_not_charge_or_poison_next_request() -> None: + platform = FlowPlatform() + started = asyncio.Event() + release = asyncio.Event() + + async def price(context: HTTPRequestContext) -> str: + started.set() + await release.wait() + return "$1" + + async with merchant(platform, price) as client: + pending = asyncio.create_task(client.get("/paid")) + await started.wait() + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + assert not platform.events + release.set() + assert (await client.get("/paid")).status_code == 402 + assert not platform.events + + +async def test_dynamic_groups_mix_with_fixed_options_without_mutation() -> None: + platform = FlowPlatform() + calls = 0 + selected = ["balance"] + + def price(context: HTTPRequestContext) -> str: + nonlocal calls + calls += 1 + return "1 USDC" + + async with ( + await Seller.create(platform.options()) as seller, + await Facilitator.create(platform.options()) as facilitator, + ): + offers = await seller.offers(price, schemes=selected) + offers += await seller.offers(price, schemes=selected) + offers += await seller.offers("2 USDC", schemes=selected) + selected.clear() + cached = await seller.config() + cached.payment_methods.clear() + app = FastAPI() + app.middleware("http")( + payment_middleware( + {"GET /paid": RouteConfig(offers)}, await seller.resource_server([facilitator]) + ) + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="https://seller.example" + ) as client: + for expected_calls in (2, 4): + response = await client.get("/paid") + assert response.status_code == 402 + required = decode_payment_required_header(response.headers["payment-required"]) + assert isinstance(required, PaymentRequired) + assert [offer.amount for offer in required.accepts] == [ + "100000000", + "100000000", + "200000000", + ] + assert calls == expected_calls + + +@pytest.mark.parametrize( + "selection", ["exact", "balance", "reverse", "duplicate", "duplicate-config"] +) +@pytest.mark.parametrize("flow", ["authorization", "upfront"]) +async def test_filtered_dynamic_options_match_fixed_options( + selection: str, flow: Literal["authorization", "upfront"] +) -> None: + platform = FlowPlatform() + if selection == "duplicate-config": + platform.config["assets"].append(deepcopy(platform.config["assets"][0])) + calls = 0 + contexts: list[ReferenceType[HTTPRequestContext]] = [] + + async def price(context: HTTPRequestContext) -> str: + nonlocal calls + calls += 1 + contexts.append(ref(context)) + await asyncio.sleep(0) + return "1 USDC" + + async with ( + await Seller.create(platform.options()) as seller, + await Facilitator.create(platform.options()) as facilitator, + ): + routes = {} + for name, value in (("fixed", "1 USDC"), ("dynamic", price)): + offers = await seller.offers(value, payment_flow=flow) + if selection in ("exact", "balance"): + offers = [offer for offer in offers if offer.scheme == selection] + elif selection == "reverse": + offers = list(reversed(offers)) + elif selection == "duplicate": + offers = [offers[-1], offers[-1]] + routes["GET /" + name] = RouteConfig(offers) + app = FastAPI() + app.middleware("http")( + payment_middleware(routes, await seller.resource_server(facilitator)) + ) + + @app.get("/dynamic") + async def paid() -> dict[str, bool]: + platform.events.append("handler") + return {"paid": True} + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="https://seller.example" + ) as client: + fixed_response = await client.get("/fixed") + fixed = decode_payment_required_header(fixed_response.headers["payment-required"]) + assert isinstance(fixed, PaymentRequired) + for expected_calls in (1, 2): + response = await client.get("/dynamic") + assert response.status_code == 402 + required = decode_payment_required_header(response.headers["payment-required"]) + assert isinstance(required, PaymentRequired) + assert required.accepts == fixed.accepts + assert calls == expected_calls + assert not platform.events + payload = PaymentPayload(accepted=fixed.accepts[0], payload={"transactionId": ID}) + response = await client.get( + "/dynamic", headers={"payment-signature": encode_payment_signature_header(payload)} + ) + assert response.status_code == 200 + assert calls == 3 + assert platform.events == ( + ["settle", "handler"] if flow == "upfront" else ["verify", "handler", "settle"] + ) + gc.collect() + assert all(context() is None for context in contexts) + + +@pytest.mark.parametrize( + "case_id", ["x402.seller.sponsorship-prefers-eip2612", "x402.seller.metered-explicit"] +) +async def test_dynamic_prices_preserve_static_requirements_and_extensions(case_id: str) -> None: + case = next(case for case in CASES if case["id"] == case_id) + platform = FlowPlatform() + platform.config = deepcopy(case["input"]["config"]) + platform.supported = deepcopy( + case["input"].get("supported", {"kinds": platform.config["supported"]}) + ) + schemes = ["upto"] if case_id.endswith("metered-explicit") else ["exact"] + async with ( + await Seller.create(platform.options()) as seller, + await Facilitator.create(platform.options()) as facilitator, + ): + challenges = [] + for price in ("$1", lambda context: "$1"): + route = await seller.route(price, schemes=schemes, permit2=True) + app = FastAPI() + app.middleware("http")( + payment_middleware( + {"GET /paid": route}, + await seller.resource_server(facilitator, schemes=schemes), + ) + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="https://seller.example" + ) as client: + response = await client.get("/paid") + assert response.status_code == 402 + challenges.append( + decode_payment_required_header(response.headers["payment-required"]) + ) + assert challenges[0] == challenges[1] + + +async def test_sponsorship_follows_selected_currency_through_verification_and_settlement() -> None: + case = next(case for case in CASES if case["id"] == "x402.seller.sponsorship-prefers-eip2612") + platform = FlowPlatform() + platform.config = deepcopy(case["input"]["config"]) + platform.supported = deepcopy(case["input"]["supported"]) + asset = platform.config["assets"][0] + platform.config["assets"].extend( + [ + { + **asset, + "assetId": "0x4444444444444444444444444444444444444444", + "currency": "USDT", + "supportsEip2612": False, + "supportsEip7702": False, + }, + { + **asset, + "assetId": "0x5555555555555555555555555555555555555555", + "currency": "DAI", + "supportsEip2612": False, + "supportsEip7702": True, + }, + ] + ) + calls: list[str] = [] + + async def price(context: HTTPRequestContext) -> str: + currency = context.adapter.get_query_param("currency") + assert isinstance(currency, str) + calls.append(currency) + await asyncio.sleep(0.001 if currency == "USDC" else 0) + return "1 " + currency + + async with ( + await Seller.create(platform.options()) as seller, + await Facilitator.create(platform.options()) as facilitator, + ): + resource = await seller.resource_server(facilitator) + route = await seller.route(price, schemes=["exact"], permit2=True) + assert route.extensions is not None + route.extensions["custom"] = {"info": {"keep": True}} + original = dict(route.extensions) + observed: list[tuple[str, object]] = [] + + def verifying(context: VerifyContext) -> None: + observed.append(("verify", deepcopy(dict(context.declared_extensions or {})))) + + def settling(context: SettleContext) -> None: + observed.append(("settle", deepcopy(dict(context.declared_extensions or {})))) + + resource.on_before_verify(verifying) + resource.on_before_settle(settling) + routes = {"GET /paid": route} + currencies = ["USDC", "USDT", "DAI", "USD"] + for currency in currencies: + fixed_route = await seller.route("1 " + currency, schemes=["exact"], permit2=True) + fixed_route.extensions = {**(fixed_route.extensions or {}), **original} + routes["GET /fixed/" + currency] = fixed_route + app = FastAPI() + app.middleware("http")(payment_middleware(routes, resource)) + + @app.get("/paid") + async def paid() -> dict[str, bool]: + return {"paid": True} + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="https://seller.example" + ) as client: + responses = await asyncio.gather( + *[client.get("/paid?currency=" + currency) for currency in currencies] + ) + for currency, response in zip(currencies, responses, strict=True): + assert response.status_code == 402 + required = decode_payment_required_header(response.headers["payment-required"]) + fixed_response = await client.get("/fixed/" + currency) + fixed = decode_payment_required_header(fixed_response.headers["payment-required"]) + assert isinstance(required, PaymentRequired) and isinstance(fixed, PaymentRequired) + assert required.accepts == fixed.accepts + assert required.extensions == fixed.extensions + expected_key = { + "USDC": "eip2612GasSponsoring", + "DAI": "inflowEip7702GasSponsoring", + }.get(currency) + assert set(required.extensions or {}) == ( + {"custom", expected_key} if expected_key else {"custom"} + ) + payload = PaymentPayload( + accepted=required.accepts[0], payload={"transactionId": ID} + ) + paid_response = await client.get( + "/paid?currency=" + currency, + headers={"payment-signature": encode_payment_signature_header(payload)}, + ) + assert paid_response.status_code == 200 + assert observed[-2:] == [ + ("verify", required.extensions), + ("settle", required.extensions), + ] + assert dict(route.extensions) == original + assert len(calls) == 8 + + +@pytest.mark.parametrize("method", ["inflow", "instrument", "tempo", "stripe", "card"]) +async def test_mpp_helpers_resolve_current_request(method: str) -> None: + platform = ( + stripe_platform() + if method == "stripe" + else card_platform() + if method == "card" + else Platform() + ) + if method == "tempo": + assert isinstance(platform.config, dict) + platform.config["supportedMethods"] = [{"id": "tempo"}] + calls = 0 + + def price(request: Request) -> WireObject: + nonlocal calls + calls += 1 + large = request.query_params.get("bundle") == "large" + if method == "tempo": + return { + "amount": "2000000" if large else "1000000", + "currency": "0x" + "1" * 40, + "recipient": "0x" + "2" * 40, + } + return { + "amount": "2" if large else "1", + "currency": "USD" if method == "instrument" else "USDC", + } + + async with await MppSeller.create( + options(platform), method="inflow" if method == "instrument" else method + ) as seller: + terms = ( + seller.stripe_request(price) + if method == "stripe" + else seller.card_request(price) + if method == "card" + else seller.charge_request(price) + ) + app = FastAPI() + + @app.get("/paid") + @pay( + intent=seller, + method=seller.method, + request=terms, + realm="seller.example", + secret_key="test-only", + ) + async def paid(request: Request, credential: Credential, receipt: Receipt) -> JSONResponse: + return JSONResponse({"paid": True}) + + assert calls == 0 + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="https://seller.example" + ) as client: + for suffix, multiplier in (("", 1), ("?bundle=large", 2)): + response = await client.get("/paid" + suffix) + assert response.status_code == 402 + challenge = Challenge.from_www_authenticate(response.headers["www-authenticate"]) + scale = 100 if method in ("stripe", "card") else 1000000 if method == "tempo" else 1 + assert challenge.request["amount"] == str(multiplier * scale) + assert calls == 2 + assert len(platform.requests) == 1 + + +async def test_mpp_paid_retry_rejects_a_changed_price() -> None: + platform = Platform() + amount = "1" + calls = 0 + handled = 0 + + def price(request: Request) -> WireObject: + nonlocal calls + calls += 1 + return {"amount": amount, "currency": "USDC"} + + async with await MppSeller.create(options(platform)) as seller: + app = FastAPI() + + @app.get("/paid") + @pay( + intent=seller, + method=seller.method, + request=seller.charge_request(price), + realm="seller.example", + secret_key="test-only", + ) + async def paid(request: Request, credential: Credential, receipt: Receipt) -> JSONResponse: + nonlocal handled + handled += 1 + return JSONResponse({"paid": True}) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="https://seller.example" + ) as client: + challenge = Challenge.from_www_authenticate( + (await client.get("/paid")).headers["www-authenticate"] + ) + authorization = Credential( + challenge=challenge.to_echo(), payload={"transactionId": ID} + ).to_authorization() + amount = "2" + assert ( + await client.get("/paid", headers={"authorization": authorization}) + ).status_code == 402 + assert len(platform.requests) == 1 + amount = "1" + assert ( + await client.get("/paid", headers={"authorization": authorization}) + ).status_code == 200 + assert [request.url.path for request in platform.requests][-2:] == [ + "/v1/mpp/validate", + "/v1/mpp/broadcast", + ] + assert handled == 1 + assert calls == 3 + + +async def test_mpp_callback_failure_and_unsupported_currency_do_not_pay() -> None: + platform = Platform() + mode = "raises" + supplied: WireObject = {"amount": "1", "currency": "UNKNOWN", "methodDetails": {}} + before = deepcopy(supplied) + + def price(request: Request) -> WireObject: + if mode == "raises": + raise RuntimeError("Price unavailable") + return supplied + + async with await MppSeller.create(options(platform)) as seller: + app = FastAPI() + + @app.get("/paid") + @pay( + intent=seller, + method=seller.method, + request=seller.charge_request(price), + realm="seller.example", + secret_key="test-only", + ) + async def paid(request: Request, credential: Credential, receipt: Receipt) -> JSONResponse: + pytest.fail("Invalid pricing must not reach the paid handler") + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app, raise_app_exceptions=False), + base_url="https://seller.example", + ) as client: + assert (await client.get("/paid")).status_code == 500 + mode = "unsupported" + assert (await client.get("/paid")).status_code == 500 + assert len(platform.requests) == 1 + assert supplied == before