From fd69c495e0d4d55c912e13b09c0c67f968399ff2 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 14 May 2026 14:49:00 -0700 Subject: [PATCH 1/2] feat(checkout): add Checkout orchestrator for one-call agent-commerce flow Composes 402-emit + verify+settle into a single 'await checkout.handle()' call. Services every merchant shape via the same primitive: - Goods sellers: wire on_settled for order persistence + mint_recipients for Stripe-multichain per-order addresses. - API sellers: wire compute_pricing for per-call billing; on_settled can return the inline API response body. - Self-custody / Stripe / mixed: rails dict is the single source of truth. - x402 only / MPP only / both: each handler is independently optional. - Gated / ungated identity: CheckoutRequest.assess is optional. Framework-neutral: handle() takes CheckoutRequest, returns CheckoutResult (body + headers + status + reference_id + settled). Domain-neutral: reference_id is a UUID; goods merchants persist as order id, API merchants treat as request id. x402 base network is auto-derived from rails['x402_base'].network. No duplicate kwarg. CheckoutRequest.raw is an escape hatch for compose_mppx hooks that need the framework's native request object. --- agentscore_commerce/__init__.py | 24 ++ agentscore_commerce/checkout.py | 574 ++++++++++++++++++++++++++++++++ tests/test_checkout.py | 369 ++++++++++++++++++++ 3 files changed, 967 insertions(+) create mode 100644 agentscore_commerce/checkout.py create mode 100644 tests/test_checkout.py diff --git a/agentscore_commerce/__init__.py b/agentscore_commerce/__init__.py index 8cde716..5fe689b 100644 --- a/agentscore_commerce/__init__.py +++ b/agentscore_commerce/__init__.py @@ -6,12 +6,24 @@ agentscore_commerce.discovery - probe + .well-known/mpp.json + llms.txt + OpenAPI snippets agentscore_commerce.challenge - 402-body builders agentscore_commerce.stripe_multichain - multichain PaymentIntent helpers + agentscore_commerce.checkout - high-level Checkout orchestrator agentscore_commerce.api - AgentScore SDK re-export """ from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version +from agentscore_commerce.checkout import ( + Checkout, + CheckoutContext, + CheckoutRailSpec, + CheckoutRequest, + CheckoutResult, + MppxComposeOutcome, + PricingResult, + SettleOutcome, +) + try: __version__ = _pkg_version("agentscore-commerce") except PackageNotFoundError: @@ -19,3 +31,15 @@ # don't crash on a missing dist-info dir. Real version always comes from # pyproject.toml at install time. __version__ = "0.0.0+local" + +__all__ = [ + "Checkout", + "CheckoutContext", + "CheckoutRailSpec", + "CheckoutRequest", + "CheckoutResult", + "MppxComposeOutcome", + "PricingResult", + "SettleOutcome", + "__version__", +] diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py new file mode 100644 index 0000000..2f6c3cb --- /dev/null +++ b/agentscore_commerce/checkout.py @@ -0,0 +1,574 @@ +"""High-level Checkout orchestrator — composes 402-emit + verify+settle. + +The Checkout primitive collapses the agent-commerce dance (emit 402 → +verify+settle on retry → respond) into a single ``await +checkout.handle(request)`` call. It services every merchant shape: + +* **Goods sellers** wire inventory hooks (``on_settled`` persists the order; + ``mint_recipients`` mints per-order Stripe-multichain addresses). +* **API sellers** wire per-call billing (``compute_pricing`` returns a fixed + amount; ``on_settled`` returns the inline API response body). +* **Self-custody-only merchants** configure chain rails (Tempo / Base / Solana) + via ``X402BaseRailSpec`` / ``TempoRailSpec`` / ``SolanaMppRailSpec``. +* **Custodial-only merchants** configure ``StripeRailSpec`` and skip the chain + rails — Stripe SPT settles via the same ``compose_mppx`` hook. +* **Multi-rail merchants** configure all of the above; the agent picks the rail. + +Three flexibility axes — every combination is supported: + +* **x402 only / MPP only / both** — Checkout works with ``x402_server`` alone, + ``compose_mppx`` alone, or both. Whichever payment header arrives is dispatched + to the configured handler; the other path is simply absent. +* **Self-custody / Stripe / mixed** — rails dict is the single source of truth. + Listing ``StripeRailSpec`` makes Stripe SPT an acceptable rail; omitting it + makes the merchant chain-only. Mixing freely is the default. +* **Gated / ungated identity** — ``CheckoutRequest.assess`` is optional. Merchants + who run :class:`AgentScoreGate` upstream pass its result through; merchants + running anonymous (per-call API, public discovery) leave it ``None``. + +Domain-neutral by design: every per-request value is keyed by +``reference_id`` (a UUID minted on first contact). Goods merchants persist +this as their order id; API merchants treat it as a per-call request id. + +Usage (goods seller, full agent-commerce flow):: + + checkout = Checkout( + rails={ + "tempo": TempoRailSpec(recipient=...), + "x402_base": X402BaseRailSpec(recipient=...), + "stripe": StripeRailSpec(profile_id=...), + }, + url=APP_URL, + compute_pricing=lambda ctx: PricingResult(amount_usd=cart_total(ctx.body)), + mint_recipients=lambda ctx: stripe_multichain_addresses_for(ctx.amount_usd), + on_settled=lambda ctx, outcome: persist_order(ctx.reference_id, ctx.body, outcome), + compose_mppx=lambda ctx: mppx_compose(mppx, ctx.request), + x402_server=x402, + x402_base_network="eip155:8453", + ) + +Usage (API seller, per-call billing with inline response):: + + checkout = Checkout( + rails={"x402_base": X402BaseRailSpec(recipient=TREASURY, mode="exact")}, + url=APP_URL, + compute_pricing=lambda ctx: PricingResult(amount_usd=0.01), + on_settled=lambda ctx, outcome: {"data": await run_api_call(ctx.body)}, + x402_server=x402, + x402_base_network="eip155:8453", + # compose_mppx omitted — x402-only API merchants don't need MPP rails + ) + +``handle(request)`` returns a framework-neutral :class:`CheckoutResult` +(``body`` + ``headers`` + ``status`` + ``reference_id`` + ``settled``); the +merchant wraps it in their framework's response shape. +""" + +from __future__ import annotations + +import inspect +import uuid +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any, Literal, TypeAlias + +from agentscore_commerce.challenge.accepted_methods import build_accepted_methods +from agentscore_commerce.challenge.agent_instructions import build_agent_instructions +from agentscore_commerce.challenge.agent_memory import first_encounter_agent_memory +from agentscore_commerce.challenge.body import build_402_body +from agentscore_commerce.challenge.how_to_pay import build_how_to_pay +from agentscore_commerce.challenge.pricing import PricingBlock, build_pricing_block +from agentscore_commerce.challenge.respond_402 import Respond402Result, respond_402 +from agentscore_commerce.challenge.validation_error import build_validation_error +from agentscore_commerce.payment.rail_spec import ( + RecipientLike, + SolanaMppRailSpec, + StripeRailSpec, + TempoRailSpec, + TempoSessionRailSpec, + X402BaseRailSpec, +) +from agentscore_commerce.payment.x402_settle import ( + ProcessX402SettleSuccess, + process_x402_settle, +) +from agentscore_commerce.payment.x402_validation import ( + VerifyX402RequestSuccess, + verify_x402_request, +) + +CheckoutRailSpec: TypeAlias = ( + TempoRailSpec | X402BaseRailSpec | SolanaMppRailSpec | StripeRailSpec | TempoSessionRailSpec +) + + +@dataclass +class CheckoutRequest: + """Framework-neutral HTTP request input to :meth:`Checkout.handle`. + + Merchants build this from their framework's request object once; the + Checkout layer then runs the same flow regardless of FastAPI / Flask / + Django / aiohttp / Sanic. + """ + + method: str + url: str + headers: dict[str, str] + body: dict[str, Any] + """Parsed JSON body. For non-JSON endpoints, pass ``{}`` and stash the raw bytes elsewhere.""" + assess: dict[str, Any] | None = None + """Optional assess block from the gate (operator/wallet identity, signer verdicts). + + When present, hooks can branch on identity (e.g. KYC-only pricing). When absent, + the merchant is either running pre-gate (anonymous discovery) or chose to skip + the gate for this endpoint. + """ + raw: Any = None + """Optional escape hatch for the framework's native request object. Pass when + your ``compose_mppx`` hook needs to call ``mppx.compose(...)(raw_request)`` — + pympp's compose binds to the raw HTTP request, so the orchestrator forwards + this through unchanged.""" + + +@dataclass +class PricingResult: + """Output of :attr:`Checkout.compute_pricing` — per-request pricing.""" + + amount_usd: float + """Total to charge in USD (or the upper bound, for ``mode="upto"`` rails).""" + currency: str = "USD" + block: PricingBlock | None = None + """Optional pre-built :class:`PricingBlock`. When omitted, Checkout builds a minimal + block from ``amount_usd`` so the 402 body always carries pricing metadata.""" + + +@dataclass +class CheckoutContext: + """In-flight state passed to every hook in the Checkout flow.""" + + request: CheckoutRequest + reference_id: str + """UUID minted on first contact. Goods merchants persist as order id; API merchants + treat as request id.""" + pricing: PricingResult | None = None + """Set after :attr:`Checkout.compute_pricing` runs; ``None`` before.""" + recipients: dict[str, str] = field(default_factory=dict) + """rail-key → recipient address, after :attr:`Checkout.mint_recipients` runs (if + provided). Static rails (treasury-funded) inherit recipients from the RailSpec.""" + + +@dataclass +class SettleOutcome: + """Surface passed to :attr:`Checkout.on_settled` after a payment lands.""" + + rail: Literal["x402", "mpp"] + """Which protocol settled. ``"mpp"`` covers tempo / tempo-session / solana / stripe-spt.""" + payment_response_header: str | None = None + """The ``PAYMENT-RESPONSE`` header to echo (x402 success path). ``None`` for MPP.""" + raw: Any = None + """The underlying settle result (``ProcessX402SettleSuccess`` or merchant-supplied + MPP compose result) for merchants that need to inspect tx hash / facilitator details.""" + + +@dataclass +class MppxComposeOutcome: + """Result a ``compose_mppx`` hook returns when handling an MPP credential. + + ``status=200`` means pympp validated the ``Authorization: Payment`` credential + and the settlement landed — Checkout runs ``on_settled`` and returns success. + + ``status=402`` means pympp emitted a 402 (no credential / invalid credential). + Checkout layers its rich body on top of pympp's WWW-Authenticate header and + optional x402 PAYMENT-REQUIRED, returning the composed 402. + """ + + status: Literal[200, 402] + headers: dict[str, str] = field(default_factory=dict) + """For ``status=402``: the WWW-Authenticate (+ any other) headers pympp's + compose emitted. Checkout merges these into the final 402 response.""" + payment_response_header: str | None = None + """For ``status=200``: optional PAYMENT-RESPONSE header echoed to the agent.""" + raw: Any = None + """The underlying pympp compose result for ``on_settled`` introspection.""" + + +@dataclass +class CheckoutResult: + """Framework-neutral output of :meth:`Checkout.handle`.""" + + status: int + body: dict[str, Any] + headers: dict[str, str] + reference_id: str + settled: bool = False + settle_phase: str | None = None + """``None`` on settlement success; otherwise the failure phase (``"verify_failed"``, + ``"settle_failed"``, ...) for diagnostics.""" + + +PricingFn: TypeAlias = Callable[["CheckoutContext"], "Awaitable[PricingResult] | PricingResult"] +RecipientsFn: TypeAlias = Callable[["CheckoutContext"], "Awaitable[dict[str, str]] | dict[str, str]"] +ReferenceIdFn: TypeAlias = Callable[["CheckoutContext"], "Awaitable[str] | str"] +OnSettledFn: TypeAlias = Callable[ + ["CheckoutContext", "SettleOutcome"], + "Awaitable[dict[str, Any] | None] | dict[str, Any] | None", +] +ComposeMppxFn: TypeAlias = Callable[["CheckoutContext"], "Awaitable[MppxComposeOutcome] | MppxComposeOutcome"] +IsCachedAddressFn: TypeAlias = Callable[[str], "Awaitable[bool] | bool"] + + +def _has_x402_header(headers: dict[str, str]) -> bool: + lower = {k.lower(): v for k, v in headers.items()} + return bool(lower.get("payment-signature") or lower.get("x-payment")) + + +def _has_mppx_header(headers: dict[str, str]) -> bool: + lower = {k.lower(): v for k, v in headers.items()} + auth = lower.get("authorization") or "" + return auth.startswith("Payment ") + + +async def _maybe_await(value: Any) -> Any: + if hasattr(value, "__await__"): + return await value + return value + + +class Checkout: + """High-level agent-commerce orchestrator. + + Composes :func:`build_accepted_methods`, :func:`build_how_to_pay`, + :func:`respond_402`, :func:`verify_x402_request`, and + :func:`process_x402_settle` into a single ``await checkout.handle(request)`` + call. For MPP rails, the merchant supplies a ``compose_mppx`` hook that + drives pympp's ``compose()`` (intent dispatch is merchant-owned because + pympp binds intents per instance). + + Required: + + * ``rails`` — rail-key → ``*RailSpec``. The same map every other helper + consumes (:func:`build_accepted_methods`, :func:`build_how_to_pay`, + :func:`create_mppx_server`). + * ``url`` — absolute URL of the checkout endpoint. + * ``compute_pricing`` — async/sync function ``(ctx) -> PricingResult``. + + Optional: + + * ``x402_server`` — built via :func:`create_x402_server`. Pair it with an + ``X402BaseRailSpec`` in ``rails["x402_base"]``; the CAIP-2 network is + read from ``rail.network`` (defaults to ``eip155:8453``). + * ``compose_mppx`` — async/sync function ``(ctx) -> MppxComposeOutcome``. + Required when the merchant accepts ``Authorization: Payment`` credentials + (Tempo / Solana MPP / Stripe SPT). Omit for x402-only merchants. + * ``mint_recipients`` — async/sync function ``(ctx) -> dict[rail_key, address]``. + Use for Stripe-multichain merchants who mint per-order deposit addresses. + When omitted, every rail's recipient is taken from its ``*RailSpec``. + * ``mint_reference_id`` — async/sync function ``(ctx) -> str``. Default is + :func:`uuid.uuid4`. Goods merchants typically mint an order id here. + * ``on_settled`` — async/sync function ``(ctx, outcome) -> dict | None``. Runs + after the payment settles successfully. Goods merchants persist the order + here. API merchants can return the inline API response body — when the hook + returns a dict, it becomes the 200 response body (with ``reference_id`` + auto-merged). + * ``is_cached_address`` — pass when the merchant mints per-order addresses + so :func:`verify_x402_request` can confirm the ``payTo`` was minted by + this merchant. Default permissive (accepts any payTo) for static-treasury + merchants. + """ + + def __init__( + self, + *, + rails: dict[str, CheckoutRailSpec], + url: str, + compute_pricing: PricingFn, + x402_server: Any = None, + compose_mppx: ComposeMppxFn | None = None, + mint_recipients: RecipientsFn | None = None, + mint_reference_id: ReferenceIdFn | None = None, + on_settled: OnSettledFn | None = None, + is_cached_address: IsCachedAddressFn | None = None, + ) -> None: + if x402_server is not None: + base_spec = rails.get("x402_base") + if not isinstance(base_spec, X402BaseRailSpec): + msg = ( + "Checkout: x402_server requires an X402BaseRailSpec in " + "rails['x402_base'] (the rail's `network` field supplies the CAIP-2)." + ) + raise ValueError(msg) + self.rails = rails + self.url = url + self.compute_pricing = compute_pricing + self.x402_server = x402_server + self.compose_mppx = compose_mppx + self.mint_recipients = mint_recipients + self.mint_reference_id = mint_reference_id + self.on_settled = on_settled + self.is_cached_address = is_cached_address + + @property + def _x402_base_network(self) -> str | None: + """CAIP-2 read from ``rails['x402_base'].network`` (or its default). + + Defined only when ``x402_server`` is configured + an ``X402BaseRailSpec`` is + present in rails; otherwise ``None``. + """ + if self.x402_server is None: + return None + spec = self.rails.get("x402_base") + if not isinstance(spec, X402BaseRailSpec): + return None + return spec.network + + async def handle(self, request: CheckoutRequest) -> CheckoutResult: + """One-call agent-commerce flow. + + * x402 ``X-Payment`` header present → verify, settle via ``x402_server``, + run ``on_settled`` hook, return 200 with the hook's body (or + ``{ok: true, reference_id}``). + * MPP ``Authorization: Payment`` header present + ``compose_mppx`` hook + configured → invoke hook, 200 / 402 outcome composed. + * Otherwise → emit 402 with all configured rails. + """ + reference_id = await self._mint_reference_id(request) + ctx = CheckoutContext(request=request, reference_id=reference_id) + ctx.pricing = await _maybe_await(self.compute_pricing(ctx)) + + if _has_x402_header(request.headers) and self.x402_server is not None and self._x402_base_network: + return await self._handle_x402(ctx) + + if _has_mppx_header(request.headers) and self.compose_mppx is not None: + return await self._handle_mppx(ctx) + + return await self._emit_402(ctx) + + async def _async_is_cached_address(self, addr: str) -> bool: + if self.is_cached_address is None: + return True + out = self.is_cached_address(addr) + if inspect.isawaitable(out): + return await out + return bool(out) + + async def _mint_reference_id(self, request: CheckoutRequest) -> str: + if self.mint_reference_id is None: + return str(uuid.uuid4()) + ctx = CheckoutContext(request=request, reference_id="") + return str(await _maybe_await(self.mint_reference_id(ctx))) + + async def _resolve_recipients(self, ctx: CheckoutContext) -> dict[str, str]: + if self.mint_recipients is None: + return {} + ctx.recipients = dict(await _maybe_await(self.mint_recipients(ctx))) + return ctx.recipients + + async def _handle_x402(self, ctx: CheckoutContext) -> CheckoutResult: + if ctx.pricing is None or self._x402_base_network is None: + msg = "Checkout._handle_x402: missing pricing or x402 rail config" + raise RuntimeError(msg) + verified = await verify_x402_request( + headers=ctx.request.headers, + is_cached_address=self._async_is_cached_address, + accepted_network=self._x402_base_network, + ) + if not isinstance(verified, VerifyX402RequestSuccess): + return CheckoutResult( + status=verified.status, + body=verified.body, + headers={}, + reference_id=ctx.reference_id, + settled=False, + settle_phase="verify_failed", + ) + settle = await process_x402_settle( + x402_server=self.x402_server, + payload=verified.payload, + resource_config={ + "scheme": "exact", + "network": verified.signed_network, + "price": f"${ctx.pricing.amount_usd}", + "payTo": verified.signed_pay_to, + "maxTimeoutSeconds": 300, + }, + resource_meta={ + "url": ctx.request.url, + "description": "Agent purchase via x402", + "mimeType": "application/json", + }, + ) + if not isinstance(settle, ProcessX402SettleSuccess): + return CheckoutResult( + status=400, + body=build_validation_error( + code="payment_proof_invalid", + message=f"Payment failed during settlement (phase: {settle.phase or 'unknown'}).", + next_steps={"action": "regenerate_payment_credential"}, + extra={"phase": settle.phase}, + ), + headers={}, + reference_id=ctx.reference_id, + settled=False, + settle_phase=settle.phase or "settle_failed", + ) + outcome = SettleOutcome( + rail="x402", + payment_response_header=settle.payment_response_header, + raw=settle, + ) + return await self._build_success(ctx, outcome) + + async def _handle_mppx(self, ctx: CheckoutContext) -> CheckoutResult: + if self.compose_mppx is None: + msg = "Checkout._handle_mppx: compose_mppx hook not configured" + raise RuntimeError(msg) + composed: MppxComposeOutcome = await _maybe_await(self.compose_mppx(ctx)) + if composed.status == 200: + outcome = SettleOutcome( + rail="mpp", + payment_response_header=composed.payment_response_header, + raw=composed.raw, + ) + return await self._build_success(ctx, outcome) + return await self._emit_402(ctx, mppx_headers=composed.headers) + + async def _emit_402( + self, + ctx: CheckoutContext, + mppx_headers: dict[str, str] | None = None, + ) -> CheckoutResult: + if ctx.pricing is None: + msg = "Checkout._emit_402: pricing not computed" + raise RuntimeError(msg) + await self._resolve_recipients(ctx) + emit_rails = _apply_recipient_overrides(self.rails, ctx.recipients) + + accepted = await build_accepted_methods( + tempo=_pick(emit_rails, "tempo", TempoRailSpec), + x402_base=_pick(emit_rails, "x402_base", X402BaseRailSpec), + solana_mpp=_pick(emit_rails, "solana_mpp", SolanaMppRailSpec), + stripe=_pick(emit_rails, "stripe", StripeRailSpec), + ) + how_to_pay_rails: dict[str, TempoRailSpec | X402BaseRailSpec | SolanaMppRailSpec | StripeRailSpec] = { + k: v + for k, v in emit_rails.items() + if isinstance(v, (TempoRailSpec, X402BaseRailSpec, SolanaMppRailSpec, StripeRailSpec)) + } + how_to_pay = await build_how_to_pay( + url=self.url, + retry_body_json=str(ctx.request.body), + total_usd=str(ctx.pricing.amount_usd), + rails=how_to_pay_rails, + ) + pricing_block = ctx.pricing.block or build_pricing_block( + subtotal_cents=round(ctx.pricing.amount_usd * 100), + currency=ctx.pricing.currency, + ) + body = build_402_body( + accepted_methods=accepted, + agent_instructions=build_agent_instructions(how_to_pay=how_to_pay), + pricing=pricing_block, + amount_usd=str(ctx.pricing.amount_usd), + retry_body=ctx.request.body, + agent_memory=first_encounter_agent_memory(first_encounter=True), + ) + + x402_kwargs: dict[str, Any] | None = None + x402_network = self._x402_base_network + if self.x402_server is not None and x402_network: + from agentscore_commerce.payment.x402_server import build_x402_accepts_for_402 + + base_spec = emit_rails.get("x402_base") + if isinstance(base_spec, X402BaseRailSpec): + recipient = await _resolve_recipient_value(base_spec.recipient) + x402_kwargs = { + "x402_version": 2, + "accepts": build_x402_accepts_for_402( + self.x402_server, + network=x402_network, + price=f"${ctx.pricing.amount_usd}", + pay_to=recipient, + max_timeout_seconds=300, + ), + "resource": {"url": ctx.request.url, "mimeType": "application/json"}, + } + + respond = respond_402( + mppx_challenge_headers=mppx_headers or {}, + body=body, + x402=x402_kwargs, + ) + return CheckoutResult( + status=respond.status, + body=respond.body, + headers=respond.headers, + reference_id=ctx.reference_id, + settled=False, + ) + + async def _build_success(self, ctx: CheckoutContext, outcome: SettleOutcome) -> CheckoutResult: + custom_body: dict[str, Any] | None = None + if self.on_settled is not None: + result = await _maybe_await(self.on_settled(ctx, outcome)) + if isinstance(result, dict): + custom_body = result + body: dict[str, Any] = custom_body if custom_body is not None else {"ok": True} + body.setdefault("reference_id", ctx.reference_id) + headers: dict[str, str] = {} + if outcome.payment_response_header: + headers["payment-response"] = outcome.payment_response_header + return CheckoutResult( + status=200, + body=body, + headers=headers, + reference_id=ctx.reference_id, + settled=True, + ) + + +async def _resolve_recipient_value(r: RecipientLike) -> str: + from agentscore_commerce.payment.rail_spec import resolve_recipient + + return await resolve_recipient(r) + + +def _pick(rails: dict[str, CheckoutRailSpec], key: str, expected: type) -> Any: + """Return ``rails[key]`` when it's an instance of ``expected``, else ``None``.""" + spec = rails.get(key) + return spec if isinstance(spec, expected) else None + + +def _apply_recipient_overrides( + rails: dict[str, CheckoutRailSpec], + overrides: dict[str, str], +) -> dict[str, CheckoutRailSpec]: + """Apply per-call recipient overrides (from ``mint_recipients``) to rail specs. + + Returns a new dict; original rails dict is not mutated. Stripe rails are + passed through unchanged (no on-chain recipient — they use ``profile_id``). + """ + if not overrides: + return rails + out: dict[str, CheckoutRailSpec] = {} + for key, spec in rails.items(): + override = overrides.get(key) + if override is None or isinstance(spec, StripeRailSpec): + out[key] = spec + continue + from dataclasses import replace + + out[key] = replace(spec, recipient=override) + return out + + +__all__ = [ + "Checkout", + "CheckoutContext", + "CheckoutRailSpec", + "CheckoutRequest", + "CheckoutResult", + "MppxComposeOutcome", + "PricingResult", + "Respond402Result", + "SettleOutcome", +] diff --git a/tests/test_checkout.py b/tests/test_checkout.py new file mode 100644 index 0000000..453ddf8 --- /dev/null +++ b/tests/test_checkout.py @@ -0,0 +1,369 @@ +"""Tests for the Checkout orchestrator covering every flexibility axis. + +Matrix: + +* x402-only / MPP-only / both +* self-custody (chain rails) / custodial (Stripe) / mixed +* gated identity / ungated +* goods seller (on_settled persists order) / API seller (on_settled returns inline body) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from agentscore_commerce.checkout import ( + Checkout, + CheckoutContext, + CheckoutRequest, + MppxComposeOutcome, + PricingResult, +) +from agentscore_commerce.payment.rail_spec import ( + SolanaMppRailSpec, + StripeRailSpec, + TempoRailSpec, + X402BaseRailSpec, +) + + +def _req(*, headers: dict[str, str] | None = None, body: dict[str, Any] | None = None) -> CheckoutRequest: + return CheckoutRequest( + method="POST", + url="https://api.example/purchase", + headers=headers or {}, + body=body or {"item": "wine"}, + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# 402 emit — every rail combination +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_emit_402_x402_only_no_mppx_no_identity() -> None: + """API seller pattern: x402-only, anonymous (no assess), per-call billing.""" + checkout = Checkout( + rails={"x402_base": X402BaseRailSpec(recipient="0xTREASURY")}, + url="https://api.example/call", + compute_pricing=lambda _ctx: PricingResult(amount_usd=0.01), + x402_server=None, + # x402_base_network omitted — emit-only, no settle handler + ) + result = await checkout.handle(_req()) + assert result.status == 402 + assert result.settled is False + assert "accepted_methods" in result.body + assert result.reference_id + + +@pytest.mark.asyncio +async def test_emit_402_mpp_only_no_x402() -> None: + """MPP-only goods seller: tempo + stripe SPT, no x402.""" + checkout = Checkout( + rails={ + "tempo": TempoRailSpec(recipient="0xtempo"), + "stripe": StripeRailSpec(profile_id="profile_x"), + }, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=250.0), + ) + result = await checkout.handle(_req()) + assert result.status == 402 + # No PAYMENT-REQUIRED header since x402 isn't configured + assert "payment-required" not in result.headers + + +@pytest.mark.asyncio +async def test_emit_402_all_rails_with_x402_payment_required() -> None: + """Multi-rail merchant: every supported rail advertised, x402 PAYMENT-REQUIRED layered.""" + + @dataclass + class _FakeX402Server: + _schemes: dict[str, dict[str, Any]] = field(default_factory=dict) + + def build_payment_requirements(self, config: Any) -> Any: + class _Req: + def model_dump(self, **_kwargs: Any) -> dict[str, Any]: + return { + "scheme": "exact", + "network": config.network, + "payTo": config.pay_to, + "maxAmountRequired": "10000", + "extra": {"name": "USD Coin", "version": "2"}, + } + + return [_Req()] + + checkout = Checkout( + rails={ + "tempo": TempoRailSpec(recipient="0xtempo"), + "x402_base": X402BaseRailSpec(recipient="0xbase"), + "solana_mpp": SolanaMppRailSpec(recipient="solanaaddr"), + "stripe": StripeRailSpec(profile_id="profile_x"), + }, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0), + x402_server=_FakeX402Server(), + ) + result = await checkout.handle(_req()) + assert result.status == 402 + assert "payment-required" in result.headers + + +@pytest.mark.asyncio +async def test_emit_402_custodial_only_stripe() -> None: + """Custodial-only merchant: Stripe SPT only, no chain rails.""" + checkout = Checkout( + rails={"stripe": StripeRailSpec(profile_id="profile_x")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=50.0), + ) + result = await checkout.handle(_req()) + assert result.status == 402 + assert result.body["accepted_methods"] + + +# ───────────────────────────────────────────────────────────────────────────── +# x402 settle path +# ───────────────────────────────────────────────────────────────────────────── + + +class _StubX402Server: + """Minimal x402 server fake — exercises settle path without real x402 deps. + + Mirrors x402 2.9's ``x402ResourceServer`` surface enough to pass + ``process_x402_settle``: ``build_payment_requirements(config) -> [req]``, + ``verify_payment(payload, req)``, ``settle_payment(payload, req)``. + """ + + def __init__(self, *, settle_success: bool = True) -> None: + self.settle_success = settle_success + + def build_payment_requirements(self, _config: Any) -> list[Any]: + return [{"scheme": "exact", "network": "eip155:8453"}] + + async def verify_payment(self, _payload: Any, _requirement: Any) -> Any: + @dataclass + class _Verified: + is_valid: bool = True + + return _Verified() + + async def settle_payment(self, _payload: Any, _requirement: Any) -> Any: + # ProcessX402Settle treats a falsy success as a settle_failed phase via the + # exception raised by settle_result_to_json_bytes when it tries to serialise + # an empty dict, so we shape the response as a plain JSON-serializable dict. + if not self.settle_success: + raise RuntimeError("settle rejected") + return { + "success": True, + "transaction": "0xtx", + "network": "eip155:8453", + "payer": "0xpayer", + } + + +def _x402_headers_with_payload() -> dict[str, str]: + import base64 + import json + + payload = { + "x402Version": 2, + "scheme": "exact", + "accepted": { + "network": "eip155:8453", + "payTo": "0x000000000000000000000000000000000000dEaD", + }, + "payload": { + "authorization": { + "from": "0xPAYER", + "to": "0x000000000000000000000000000000000000dEaD", + }, + }, + } + encoded = base64.b64encode(json.dumps(payload).encode()).decode() + return {"x-payment": encoded} + + +@pytest.mark.asyncio +async def test_x402_settle_success_runs_on_settled_hook() -> None: + """Goods seller: on_settled persists the order; success body merges reference_id.""" + on_settled = AsyncMock(return_value={"order_status": "queued"}) + checkout = Checkout( + rails={"x402_base": X402BaseRailSpec(recipient="0xTREASURY")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=0.01), + x402_server=_StubX402Server(settle_success=True), + on_settled=on_settled, + ) + result = await checkout.handle(_req(headers=_x402_headers_with_payload())) + assert result.status == 200 + assert result.settled is True + assert result.body["order_status"] == "queued" + assert result.body["reference_id"] == result.reference_id + on_settled.assert_awaited_once() + ctx_arg, outcome_arg = on_settled.await_args.args + assert isinstance(ctx_arg, CheckoutContext) + assert outcome_arg.rail == "x402" + + +@pytest.mark.asyncio +async def test_x402_settle_failure_returns_4xx_with_phase() -> None: + """Settle failure surfaces ``payment_proof_invalid`` + ``settle_phase`` for diagnostics.""" + checkout = Checkout( + rails={"x402_base": X402BaseRailSpec(recipient="0xTREASURY")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=0.01), + x402_server=_StubX402Server(settle_success=False), + ) + result = await checkout.handle(_req(headers=_x402_headers_with_payload())) + assert result.status == 400 + assert result.settled is False + assert result.settle_phase is not None + assert result.body["error"]["code"] == "payment_proof_invalid" + + +# ───────────────────────────────────────────────────────────────────────────── +# MPP compose path (via compose_mppx hook) +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_compose_mppx_returns_200_runs_on_settled() -> None: + """When pympp validates the credential, compose_mppx returns 200 and Checkout + runs ``on_settled``.""" + on_settled = AsyncMock(return_value=None) + compose_mppx = AsyncMock( + return_value=MppxComposeOutcome(status=200, payment_response_header="ok"), + ) + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0), + compose_mppx=compose_mppx, + on_settled=on_settled, + ) + result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"})) + assert result.status == 200 + assert result.headers["payment-response"] == "ok" + on_settled.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_compose_mppx_returns_402_composes_rich_body() -> None: + """When pympp re-emits 402, Checkout layers the rich body on top of pympp's WWW-Auth.""" + compose_mppx = AsyncMock( + return_value=MppxComposeOutcome( + status=402, + headers={"www-authenticate": 'Payment id="ord_x"'}, + ), + ) + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0), + compose_mppx=compose_mppx, + ) + result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"})) + assert result.status == 402 + assert result.headers["www-authenticate"] == 'Payment id="ord_x"' + assert "accepted_methods" in result.body + + +# ───────────────────────────────────────────────────────────────────────────── +# Custom hooks: pricing, recipient minting, reference id +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_compute_pricing_can_branch_on_identity() -> None: + """Identity-aware pricing: KYC'd agents get a different price.""" + + def price(ctx: CheckoutContext) -> PricingResult: + if ctx.request.assess and ctx.request.assess.get("identity_status") == "verified": + return PricingResult(amount_usd=8.0) + return PricingResult(amount_usd=10.0) + + checkout = Checkout( + rails={"x402_base": X402BaseRailSpec(recipient="0xTREASURY")}, + url="https://api.example/call", + compute_pricing=price, + ) + # Anonymous + anon = await checkout.handle(_req()) + assert anon.body["amount_usd"] == "10.0" + # KYC'd + verified = await checkout.handle( + CheckoutRequest( + method="POST", + url="https://api.example/call", + headers={}, + body={"item": "x"}, + assess={"identity_status": "verified"}, + ), + ) + assert verified.body["amount_usd"] == "8.0" + + +@pytest.mark.asyncio +async def test_mint_recipients_overrides_rail_recipients() -> None: + """Stripe-multichain pattern: per-order deposit addresses replace static treasury.""" + + def mint(_ctx: CheckoutContext) -> dict[str, str]: + return {"tempo": "0xPERORDER_TEMPO", "x402_base": "0xPERORDER_BASE"} + + checkout = Checkout( + rails={ + "tempo": TempoRailSpec(recipient="0xstatic_tempo"), + "x402_base": X402BaseRailSpec(recipient="0xstatic_base"), + }, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=100.0), + mint_recipients=mint, + ) + result = await checkout.handle(_req()) + assert result.status == 402 + # The 402 body's accepted_methods should reflect the minted recipients. + accepted_str = str(result.body["accepted_methods"]) + assert "0xPERORDER_TEMPO" in accepted_str + assert "0xPERORDER_BASE" in accepted_str + + +@pytest.mark.asyncio +async def test_mint_reference_id_runs_when_provided() -> None: + """Goods sellers mint their own order_id (e.g. against their orders table).""" + + async def mint() -> str: + return "ord_abc123" + + checkout = Checkout( + rails={"x402_base": X402BaseRailSpec(recipient="0xTREASURY")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=1.0), + mint_reference_id=lambda _ctx: mint(), + ) + result = await checkout.handle(_req()) + assert result.reference_id == "ord_abc123" + + +# ───────────────────────────────────────────────────────────────────────────── +# Init guards +# ───────────────────────────────────────────────────────────────────────────── + + +def test_init_requires_x402_base_railspec_when_x402_server_provided() -> None: + """x402_server demands an X402BaseRailSpec in rails['x402_base'] — the rail's + `network` field carries the CAIP-2, so there's no separate kwarg to forget.""" + with pytest.raises(ValueError, match="X402BaseRailSpec"): + Checkout( + rails={"tempo": TempoRailSpec(recipient="0xT")}, + url="https://x.example", + compute_pricing=lambda _ctx: PricingResult(amount_usd=1.0), + x402_server=object(), + ) From cde4a4d8da50ade5bf4fbebcea0816b2dd0e44a6 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 14 May 2026 14:54:54 -0700 Subject: [PATCH 2/2] fix(checkout): remove string forward refs from type aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged Awaitable as unused because it only appeared inside string forward refs. All referenced types (CheckoutContext, SettleOutcome, etc.) are defined earlier in the file, so the strings aren't needed — drop them and the linter sees Awaitable as a direct reference again. --- agentscore_commerce/checkout.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index 2f6c3cb..8a010b5 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -206,15 +206,15 @@ class CheckoutResult: ``"settle_failed"``, ...) for diagnostics.""" -PricingFn: TypeAlias = Callable[["CheckoutContext"], "Awaitable[PricingResult] | PricingResult"] -RecipientsFn: TypeAlias = Callable[["CheckoutContext"], "Awaitable[dict[str, str]] | dict[str, str]"] -ReferenceIdFn: TypeAlias = Callable[["CheckoutContext"], "Awaitable[str] | str"] +PricingFn: TypeAlias = Callable[[CheckoutContext], Awaitable[PricingResult] | PricingResult] +RecipientsFn: TypeAlias = Callable[[CheckoutContext], Awaitable[dict[str, str]] | dict[str, str]] +ReferenceIdFn: TypeAlias = Callable[[CheckoutContext], Awaitable[str] | str] OnSettledFn: TypeAlias = Callable[ - ["CheckoutContext", "SettleOutcome"], - "Awaitable[dict[str, Any] | None] | dict[str, Any] | None", + [CheckoutContext, SettleOutcome], + Awaitable[dict[str, Any] | None] | dict[str, Any] | None, ] -ComposeMppxFn: TypeAlias = Callable[["CheckoutContext"], "Awaitable[MppxComposeOutcome] | MppxComposeOutcome"] -IsCachedAddressFn: TypeAlias = Callable[[str], "Awaitable[bool] | bool"] +ComposeMppxFn: TypeAlias = Callable[[CheckoutContext], Awaitable[MppxComposeOutcome] | MppxComposeOutcome] +IsCachedAddressFn: TypeAlias = Callable[[str], Awaitable[bool] | bool] def _has_x402_header(headers: dict[str, str]) -> bool: