diff --git a/agentscore_commerce/__init__.py b/agentscore_commerce/__init__.py index 5fe689b..1538f9a 100644 --- a/agentscore_commerce/__init__.py +++ b/agentscore_commerce/__init__.py @@ -16,12 +16,77 @@ from agentscore_commerce.checkout import ( Checkout, CheckoutContext, + CheckoutGateConfig, CheckoutRailSpec, CheckoutRequest, CheckoutResult, + CheckoutValidationError, MppxComposeOutcome, PricingResult, SettleOutcome, + format_pydantic_errors, + validation_envelope, + validation_response_aiohttp, + validation_response_django, + validation_response_fastapi, + validation_response_flask, + validation_response_sanic, +) +from agentscore_commerce.checkout_hooks import make_mppx_compose_hook + +# Re-export the most commonly used helpers at the package root so consumers +# don't have to remember which submodule each one lives in. Mirrors node's +# top-level `index.ts` surface; submodule imports still work for power users. +from agentscore_commerce.identity import ( + AGENTSCORE_UCP_CAPABILITY, + FIXABLE_DENIAL_REASONS, + UCP_A2A_EXTENSION_URI, + AgentIdentity, + AgentMemoryHint, + AgentScoreCore, + AgentScoreGatePolicy, + AssessResult, + CreateSessionOnMissing, + DenialCode, + DenialReason, + PolicyBlock, + SignerVerdict, + UCPSigningKey, + UCPVerificationError, + VerifyWalletSignerResult, + build_a2a_agent_card, + build_agent_memory_hint, + build_contact_support_next_steps, + build_jwks_response, + build_signer_mismatch_body, + build_ucp_profile, + denial_reason_status, + denial_reason_to_body, + generate_ucp_signing_key, + hash_operator_token, + is_fixable_denial, + load_ucp_signing_key_from_env, + mpp_payment_handler, + sign_ucp_profile, + stripe_spt_payment_handler, + ucp_a2a_extension, + verification_agent_instructions, + verify_ucp_profile, + x402_payment_handler, +) +from agentscore_commerce.payment import ( + PaymentSigner, + SignerNetwork, + SolanaMppRailSpec, + StripeRailSpec, + TempoRailSpec, + TempoSessionRailSpec, + X402BaseRailSpec, + extract_payment_signer, + extract_signer_for_precheck, + format_usd_cents, + load_solana_fee_payer, + read_x402_payment_header, ) try: @@ -33,13 +98,70 @@ __version__ = "0.0.0+local" __all__ = [ + "AGENTSCORE_UCP_CAPABILITY", + "FIXABLE_DENIAL_REASONS", + "UCP_A2A_EXTENSION_URI", + "AgentIdentity", + "AgentMemoryHint", + "AgentScoreCore", + "AgentScoreGatePolicy", + "AssessResult", "Checkout", "CheckoutContext", + "CheckoutGateConfig", "CheckoutRailSpec", "CheckoutRequest", "CheckoutResult", + "CheckoutValidationError", + "CreateSessionOnMissing", + "DenialCode", + "DenialReason", "MppxComposeOutcome", + "PaymentSigner", + "PolicyBlock", "PricingResult", "SettleOutcome", + "SignerNetwork", + "SignerVerdict", + "SolanaMppRailSpec", + "StripeRailSpec", + "TempoRailSpec", + "TempoSessionRailSpec", + "UCPSigningKey", + "UCPVerificationError", + "VerifyWalletSignerResult", + "X402BaseRailSpec", "__version__", + "build_a2a_agent_card", + "build_agent_memory_hint", + "build_contact_support_next_steps", + "build_jwks_response", + "build_signer_mismatch_body", + "build_ucp_profile", + "denial_reason_status", + "denial_reason_to_body", + "extract_payment_signer", + "extract_signer_for_precheck", + "format_pydantic_errors", + "format_usd_cents", + "generate_ucp_signing_key", + "hash_operator_token", + "is_fixable_denial", + "load_solana_fee_payer", + "load_ucp_signing_key_from_env", + "make_mppx_compose_hook", + "mpp_payment_handler", + "read_x402_payment_header", + "sign_ucp_profile", + "stripe_spt_payment_handler", + "ucp_a2a_extension", + "validation_envelope", + "validation_response_aiohttp", + "validation_response_django", + "validation_response_fastapi", + "validation_response_flask", + "validation_response_sanic", + "verification_agent_instructions", + "verify_ucp_profile", + "x402_payment_handler", ] diff --git a/agentscore_commerce/challenge/body.py b/agentscore_commerce/challenge/body.py index 519888e..4778a2d 100644 --- a/agentscore_commerce/challenge/body.py +++ b/agentscore_commerce/challenge/body.py @@ -11,6 +11,10 @@ class X402PaymentRequired: accepts: list[Any] version: Literal[1, 2] = 2 + extensions: dict[str, Any] | None = None + """Per-endpoint x402 ``extensions`` block (e.g. Bazaar discovery declared + via ``build_bazaar_discovery_payload``). Emitted on the 402 body as + ``body.extensions`` per x402 spec when non-empty.""" def build_402_body( @@ -34,6 +38,8 @@ def build_402_body( if x402: body["x402Version"] = x402.version body["accepts"] = alias_amount_fields(x402.accepts) + if x402.extensions: + body["extensions"] = x402.extensions if amount_usd is not None: body["amount_usd"] = amount_usd if currency: diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index 8a010b5..3c43e5d 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -1,4 +1,4 @@ -"""High-level Checkout orchestrator — composes 402-emit + verify+settle. +"""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 @@ -11,18 +11,18 @@ * **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. + 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: +Three flexibility axes; every combination is supported: -* **x402 only / MPP only / both** — Checkout works with ``x402_server`` alone, +* **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. +* **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 +* **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``. @@ -56,7 +56,7 @@ 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 + # compose_mppx omitted; x402-only API merchants don't need MPP rails ) ``handle(request)`` returns a framework-neutral :class:`CheckoutResult` @@ -73,9 +73,9 @@ 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_instructions import RailKey, 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.body import X402PaymentRequired, 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 @@ -90,18 +90,71 @@ ) from agentscore_commerce.payment.x402_settle import ( ProcessX402SettleSuccess, + classify_x402_settle_result, process_x402_settle, ) from agentscore_commerce.payment.x402_validation import ( VerifyX402RequestSuccess, verify_x402_request, ) +from agentscore_commerce.payment.zero_settle import zero_amount_carve_out CheckoutRailSpec: TypeAlias = ( TempoRailSpec | X402BaseRailSpec | SolanaMppRailSpec | StripeRailSpec | TempoSessionRailSpec ) +def _spec_rail_key(spec: CheckoutRailSpec) -> RailKey: + """Map a ``*RailSpec`` instance to its canonical :data:`RailKey` slug. + + Tempo charge and Tempo session both speak MPP on Tempo, so they fold to + ``"tempo_mpp"``. + """ + if isinstance(spec, (TempoRailSpec, TempoSessionRailSpec)): + return "tempo_mpp" + if isinstance(spec, X402BaseRailSpec): + return "x402_base" + if isinstance(spec, SolanaMppRailSpec): + return "solana_mpp" + return "stripe" # StripeRailSpec is the only remaining variant in CheckoutRailSpec. + + +def _spec_method_name(spec: CheckoutRailSpec) -> str: + """Protocol-shaped method name for the ``methods: [...]`` discovery array.""" + if isinstance(spec, (TempoRailSpec, TempoSessionRailSpec)): + return "tempo/charge" + if isinstance(spec, X402BaseRailSpec): + return "x402/exact (base)" + if isinstance(spec, SolanaMppRailSpec): + return "solana/charge" + return "stripe/spt" # StripeRailSpec is the only remaining variant in CheckoutRailSpec. + + +class CheckoutValidationError(Exception): + """Raised from a :attr:`Checkout.pre_validate` hook to short-circuit with a 4xx. + + Checkout catches this and emits the canonical ``{error, next_steps}`` envelope + via :func:`build_validation_error` so merchants don't have to construct + ``JSONResponse`` themselves in the pre-validate path. + """ + + def __init__( + self, + *, + code: str, + message: str, + action: str = "fix_request", + status: int = 400, + extra: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.action = action + self.status = status + self.extra = extra + + @dataclass class CheckoutRequest: """Framework-neutral HTTP request input to :meth:`Checkout.handle`. @@ -125,14 +178,14 @@ class CheckoutRequest: """ 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)`` — + 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.""" + """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).""" @@ -140,6 +193,15 @@ class PricingResult: 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.""" + product: dict[str, str] | None = None + """Optional product block surfaced in the 402 body's ``product`` field. Goods + merchants populate ``{id, name, slug, list_price_usd, ...}``; API sellers leave + this ``None`` since per-call billing has no product concept.""" + body_extras: dict[str, Any] | None = None + """Optional merchant-specific fields merged into the 402 body alongside the + standard ``accepted_methods`` / ``agent_instructions`` / ``pricing`` blocks. + Useful for ``redemption_code_applied``, coupon hints, or any other field the + merchant wants the agent to see in the challenge body.""" @dataclass @@ -155,19 +217,141 @@ class CheckoutContext: 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.""" + state: dict[str, Any] = field(default_factory=dict) + """Merchant-supplied per-request state, populated by :attr:`Checkout.pre_validate`. + Other hooks read from here (e.g. ``ctx.state["product"]`` after pre_validate + resolved it). Stays empty when no pre_validate is configured.""" + capture_wallet: Callable[..., Any] | None = None + """Capture the signer wallet under the operator credential the gate resolved + for this request. Set by Checkout's internal gate after a successful allow when + an ``operator_token`` is present; ``None`` for wallet-authenticated requests + (no operator_token to associate) or anonymous discovery legs. + Fire-and-forget — invoke from ``on_settled`` with the recovered signer: + ``await ctx.capture_wallet(wallet_address=..., network=..., idempotency_key=...)``. + """ + + @property + def identity_status(self) -> str: + """Read the gate's identity verdict out of ``request.assess``. + + Returns ``"verified"`` / ``"unverified"`` / ``"anonymous"`` / ``"denied"``. + Defaults to ``"anonymous"`` when no gate ran for this request. + """ + assess = self.request.assess or {} + value = assess.get("identity_status") + return value if isinstance(value, str) else "anonymous" + + +@dataclass +class CheckoutGateConfig: + """Optional gate configuration for :class:`Checkout`. + + When set, Checkout runs the AgentScore identity gate on the settle leg (no + header → 402 emit only) and surfaces ``identity_status`` to hooks via + ``ctx.assess``. + + The gate flow has three customization seams: + + 1. ``run_gate`` — full escape hatch. Replaces the SDK's gate flow entirely. + Used by merchants with custom auth (e.g. enterprise SSO bridges) who + need full control. Other fields are ignored when set. + 2. ``per_request_policy`` — reads ``ctx.state`` (populated by pre_validate) + and returns a dict that overrides static gate policy fields per request. + Goods merchants resolve per-product compliance from this. + 3. ``on_denied`` — invoked AFTER the SDK builds the canonical DenialReason. + Returns a custom denial body shape, or ``None`` to keep the canonical body. + + ``create_session_on_missing`` auto-mints a verification session when no + identity is present and returns 403 with verify_url + poll instructions + instead of a bare ``missing_identity`` denial. Pass an explicit + :class:`CreateSessionOnMissing` to customize ``get_session_options`` / + ``on_before_session`` hooks; omit and Checkout auto-builds one from + ``api_key`` + ``base_url`` + ``context`` + ``merchant_name``. + """ + + api_key: str + """AgentScore API key. Required when ``run_gate`` is omitted.""" + base_url: str = "https://api.agentscore.sh" + """AgentScore API base URL. Override for self-hosted / staging deployments.""" + merchant_name: str | None = None + """Surfaced on auto-minted verification sessions (``product_name`` field) so + agents see the merchant they were paying when they hit the verify URL.""" + user_agent: str | None = None + """Optional User-Agent string prepended to the SDK's default. Useful for + per-merchant telemetry.""" + context: str = "checkout" + """Session context label minted on auto-session creation.""" + require_kyc: bool | None = None + """Require ``kyc_status == 'verified'`` on the resolved account.""" + require_sanctions_clear: bool | None = None + """Require ``sanctions_status == 'clear'`` on the resolved account.""" + min_age: int | None = None + """Minimum age in years; reads ``age_bracket`` from account verification.""" + blocked_jurisdictions: list[str] | None = None + """ISO-3166 alpha-2 list. Deny when the resolved jurisdiction matches.""" + allowed_jurisdictions: list[str] | None = None + """ISO-3166 alpha-2 list. Deny when the resolved jurisdiction is NOT in the list.""" + fail_open: bool = False + """When True, 429 / 5xx / timeouts pass through as ``allow`` (with + ``degraded=True`` on ctx.assess); compliance denials still deny.""" + cache_seconds: int = 300 + """TTL for the per-identity assess cache. Default 5 minutes.""" + chain: str | None = None + """Default chain hint passed to /v1/assess (CAIP-2).""" + create_session_on_missing: Any | None = None + """Optional :class:`CreateSessionOnMissing`. When set, missing-identity denials + auto-mint a session and return 403 with ``verify_url`` + poll instructions. + When omitted, Checkout builds a default config from ``api_key`` + ``base_url`` + + ``context`` + ``merchant_name``.""" + per_request_policy: Callable[[CheckoutContext], Any] | None = None + """Per-request policy override hook. Receives the CheckoutContext (with + ``ctx.state`` populated by pre_validate); returns a dict merged over the + static policy fields. Return ``None`` to skip the gate entirely for that + request.""" + on_denied: Callable[[CheckoutContext, Any], Any] | None = None + """Optional callback invoked AFTER the SDK builds the canonical DenialReason. + Receives ``(ctx, denial_reason)``; returns a dict with ``{status, body, + headers?}`` to override the canonical body, or ``None`` to keep it. Use this + to map gate denial codes to merchant-specific body shapes.""" + run_gate: Callable[[CheckoutContext], Any] | None = None + """Full escape hatch. When set, replaces the SDK's gate flow entirely. Other + fields above are ignored. Returns ``None`` on allow, or a dict with + ``{status, body, headers?}`` on denial. Used by merchants with custom auth + bridges (enterprise SSO) who need full control.""" @dataclass class SettleOutcome: - """Surface passed to :attr:`Checkout.on_settled` after a payment lands.""" + """Surface passed to :attr:`Checkout.on_settled` after a payment lands. + + Normalized fields (``tx_hash`` / ``signer_address`` / ``signer_network``) are + extracted by Checkout from the underlying settle result so merchants don't + need to know that x402's raw is a Pydantic ``SettleResponse`` with + ``.transaction`` while MPP's raw is a ``{credential, receipt}`` dict with + the signer hidden inside ``credential.source``. Read these directly. + """ rail: Literal["x402", "mpp"] """Which protocol settled. ``"mpp"`` covers tempo / tempo-session / solana / stripe-spt.""" + rail_key: str = "" + """The merchant's rails-dict key that handled this settle (e.g. ``"x402_base"``, + ``"tempo"``, ``"stripe"``). Read this directly in ``on_settled`` to label the + rail however the merchant persists it; saves the ``"x402" → "x402-base"`` + translation.""" + tx_hash: str | None = None + """On-chain transaction hash when the rail settled to chain. ``None`` for $0 + carve-outs, Stripe SPT, and pre-pympp-SessionIntent tempo sessions.""" + signer_address: str | None = None + """Wallet that signed the payment credential. Normalized (EVM lowercased, + Solana base58 preserved). ``None`` for rails without a signer (Stripe SPT).""" + signer_network: str | None = None + """``"evm"`` / ``"solana"`` for chain signers; ``None`` otherwise.""" 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.""" + """The underlying settle result. Inspect for power-user fields (facilitator + diagnostics, raw receipt blobs); prefer the normalized fields above for the + common case.""" @dataclass @@ -175,17 +359,33 @@ 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. + 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. + + On ``status=200``, return ``tx_hash`` / ``signer_address`` / ``signer_network`` + so they flow through to ``SettleOutcome`` without merchants having to + destructure ``raw`` per pympp version. The canonical hook + :func:`make_mppx_compose_hook` populates these for tempo MPP. """ 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.""" + rail_key: str = "tempo" + """For ``status=200``: which merchant rails-dict key handled this settle. + Defaults to ``"tempo"`` (most common MPP rail); override for Stripe SPT or + Solana MPP. Surfaced verbatim on :attr:`SettleOutcome.rail_key`.""" + tx_hash: str | None = None + """For ``status=200``: on-chain tx hash from the pympp Receipt (when settled + to chain). ``None`` for $0 carve-outs and Stripe SPT.""" + signer_address: str | None = None + """For ``status=200``: wallet that signed the MPP credential, normalized.""" + signer_network: str | None = None + """For ``status=200``: ``"evm"`` / ``"solana"`` depending on the rail.""" payment_response_header: str | None = None """For ``status=200``: optional PAYMENT-RESPONSE header echoed to the agent.""" raw: Any = None @@ -206,6 +406,10 @@ class CheckoutResult: ``"settle_failed"``, ...) for diagnostics.""" +PreValidateFn: TypeAlias = Callable[ + [CheckoutContext], + "Awaitable[dict[str, Any] | None] | dict[str, Any] | None", +] 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] @@ -246,34 +450,41 @@ class Checkout: Required: - * ``rails`` — rail-key → ``*RailSpec``. The same map every other helper + * ``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``. + * ``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 + * ``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``. + * ``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]``. + * ``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 + * ``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 + * ``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 + 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 + * ``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. + * ``zero_settle_carve_out``; when ``True`` and ``compute_pricing`` returns + ``amount_usd=0`` with a payment header attached, Checkout verifies the + credential, lifts the signer, and fires ``on_settled`` with + ``tx_hash=None`` instead of attempting an on-chain settle. Coinbase's + CDP facilitator and pympp's tempo intents both reject $0 settles outright; + this carve-out makes free-redemption flows work uniformly across rails. + Default ``False`` (every payment header attempts a real settle). """ def __init__( @@ -282,44 +493,187 @@ def __init__( rails: dict[str, CheckoutRailSpec], url: str, compute_pricing: PricingFn, + pre_validate: PreValidateFn | None = None, + # Explicit handler overrides; pass these when the merchant has custom + # x402 / MPP wiring. When omitted, Checkout auto-derives from the + # flat-config kwargs below (the common case). x402_server: Any = None, compose_mppx: ComposeMppxFn | None = None, + # Flat-config kwargs; Checkout auto-builds x402_server + compose_mppx + # from these so merchants don't write the lazy-init / hook boilerplate. + cdp_api_key_id: str | None = None, + cdp_api_key_secret: str | None = None, + mppx_secret_key: str | None = None, mint_recipients: RecipientsFn | None = None, mint_reference_id: ReferenceIdFn | None = None, on_settled: OnSettledFn | None = None, is_cached_address: IsCachedAddressFn | None = None, + zero_settle_carve_out: bool = False, + gate: CheckoutGateConfig | None = None, + discovery_extensions: dict[str, Any] | 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)." + # Auto-derive x402_server when not supplied: rails has an X402BaseRailSpec + # → lazy-init via SDK helper. Merchants only pass CDP creds (or omit + # them for the public facilitator); no manual server wiring needed. + if x402_server is None: + base_spec = next( + (spec for spec in rails.values() if isinstance(spec, X402BaseRailSpec)), + None, + ) + if base_spec is not None: + from agentscore_commerce.payment.lazy import lazy_x402_server + + x402_server_getter = lazy_x402_server( + spec=base_spec, + cdp_api_key_id=cdp_api_key_id, + cdp_api_key_secret=cdp_api_key_secret, + ) + # Cache the getter; Checkout awaits it on first settle path use. + self._x402_server_getter: Callable[[], Awaitable[Any]] | None = x402_server_getter + else: + self._x402_server_getter = None + else: + self._x402_server_getter = None + if x402_server is not None and not any(isinstance(spec, X402BaseRailSpec) for spec in rails.values()): + msg = ( + "Checkout: x402_server requires an X402BaseRailSpec in `rails` " + "(the rail's `network` field supplies the CAIP-2)." + ) + raise ValueError(msg) + + # Auto-derive compose_mppx when not supplied: any MPP rail + secret_key + # → wire make_mppx_compose_hook + lazy_mppx_server internally. + if compose_mppx is None and mppx_secret_key is not None: + mpp_rails = { + k: v + for k, v in rails.items() + if isinstance(v, (TempoRailSpec, SolanaMppRailSpec, TempoSessionRailSpec, StripeRailSpec)) + } + if mpp_rails: + from agentscore_commerce.checkout_hooks import make_mppx_compose_hook + from agentscore_commerce.payment.lazy import lazy_mppx_server + + getter = lazy_mppx_server( + rails=mpp_rails, + secret_key=mppx_secret_key, + realm=url, ) - raise ValueError(msg) + compose_mppx = make_mppx_compose_hook(server_getter=getter) + self.rails = rails self.url = url + self.merchant_name = gate.merchant_name if gate is not None else None self.compute_pricing = compute_pricing + self.pre_validate = pre_validate 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 + self.zero_settle_carve_out = zero_settle_carve_out + self.gate = gate + self.discovery_extensions = discovery_extensions + """Per-endpoint x402 ``extensions`` block emitted on the 402 body. Merge + outputs of ``build_bazaar_discovery_payload({...})`` (or other extension + declarers) here — Checkout forwards verbatim into the 402 response + body's ``extensions`` field so Bazaar crawlers and other spec-compliant + clients read the route's declared input/output schema.""" + + async def _get_x402_server(self) -> Any: + """Resolve the x402 server. + + Explicit ``x402_server`` wins; otherwise the auto-derived lazy getter + is awaited once and cached. + """ + if self.x402_server is not None: + return self.x402_server + if self._x402_server_getter is None: + return None + self.x402_server = await self._x402_server_getter() + return self.x402_server + + def _x402_server_available(self) -> bool: + """Whether Checkout can resolve an x402 server. + + True when either an explicit ``x402_server`` was supplied or an + auto-derived lazy getter is available. + """ + return self.x402_server is not None or self._x402_server_getter is not None + + @property + def accepted_rails(self) -> list[RailKey]: + """Canonical ``RailKey`` list derived from the configured rails dict. + + Each ``*RailSpec`` type maps to one ``RailKey`` (Tempo & TempoSession + both fold to ``"tempo_mpp"``). Dedupes so listing per protocol, not + per recipient address. Use in /.well-known/mpp.json, + skill.md / llms.txt discovery responses. + """ + out: list[RailKey] = [] + seen: set[str] = set() + for spec in self.rails.values(): + key = _spec_rail_key(spec) + if key in seen: + continue + seen.add(key) + out.append(key) + return out + + @property + def accepted_method_names(self) -> list[str]: + """Protocol-shaped method-name list (``"tempo/charge"``, ``"x402/exact (base)"``). + + Suitable for the ``methods: [...]`` array of + ``/.well-known/mpp.json``'s ``PaymentMethodConfig``. + """ + out: list[str] = [] + seen: set[str] = set() + for spec in self.rails.values(): + name = _spec_method_name(spec) + if name in seen: + continue + seen.add(name) + out.append(name) + return out + + def _x402_rail_key(self) -> str: + """Return the merchant's rails-dict key for the X402BaseRailSpec entry. + + Defaults to ``"x402_base"`` when no match is found. + """ + for key, spec in self.rails.items(): + if isinstance(spec, X402BaseRailSpec): + return key + return "x402_base" + + def _mpp_rail_key(self) -> str: + """Return the merchant's rails-dict key for the primary MPP rail. + + Prefers ``tempo`` (most common MPP rail today). Used by the zero-settle + carve-out path when the merchant hasn't otherwise specified rail_key. + """ + for key, spec in self.rails.items(): + if isinstance(spec, TempoRailSpec): + return key + for key, spec in self.rails.items(): + if isinstance(spec, (SolanaMppRailSpec, TempoSessionRailSpec, StripeRailSpec)): + return key + return "tempo" @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``. + Defined only when an ``X402BaseRailSpec`` is present in rails AND a + server is configured (explicit or auto-derived); otherwise ``None``. """ - if self.x402_server is None: + if not self._x402_server_available(): return None - spec = self.rails.get("x402_base") - if not isinstance(spec, X402BaseRailSpec): - return None - return spec.network + for spec in self.rails.values(): + if isinstance(spec, X402BaseRailSpec): + return spec.network + return None async def handle(self, request: CheckoutRequest) -> CheckoutResult: """One-call agent-commerce flow. @@ -333,15 +687,488 @@ async def handle(self, request: CheckoutRequest) -> CheckoutResult: """ reference_id = await self._mint_reference_id(request) ctx = CheckoutContext(request=request, reference_id=reference_id) + + # Pre-validate (optional): resolve merchant-specific per-request state + # (product lookup, code resolution, shipping checks, ...). May raise + # CheckoutValidationError to short-circuit with a 4xx; otherwise return + # a dict that's stashed on ``ctx.state`` for downstream hooks to read. + if self.pre_validate is not None: + try: + state = await _maybe_await(self.pre_validate(ctx)) + except CheckoutValidationError as err: + return CheckoutResult( + status=err.status, + body=build_validation_error( + code=err.code, + message=err.message, + next_steps={"action": err.action, "user_message": err.message}, + extra=err.extra, + ), + headers={}, + reference_id=ctx.reference_id, + settled=False, + settle_phase="pre_validate_failed", + ) + if isinstance(state, dict): + ctx.state = state + 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: + # Per-request gate: runs on the settle leg only (anonymous discovery + # passes through to 402). Sets ctx.assess["identity_status"]; 403s + # short-circuit. Merchant-supplied per_request_policy resolves the + # policy block (read from the product row, tier, etc.). + has_payment_header = _has_x402_header(request.headers) or _has_mppx_header(request.headers) + if self.gate is not None and has_payment_header: + gate_result = await self._run_gate(ctx) + if gate_result is not None: + return gate_result + + # Zero-amount carve-out: CDP rejects EIP-3009 with value=0 and pympp's + # tempo intents reject ``proof`` payloads. When pricing is $0 AND a + # payment header is present, verify the credential to lift the signer + # then short-circuit to ``on_settled`` with tx_hash=None. + if ( + self.zero_settle_carve_out + and ctx.pricing is not None + and ctx.pricing.amount_usd == 0 + and (_has_x402_header(request.headers) or _has_mppx_header(request.headers)) + ): + return await self._handle_zero_settle(ctx) + + if _has_x402_header(request.headers) and self._x402_server_available() 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) + # Discovery leg: if an MPP rail is configured (compose_mppx supplied), call + # it to mint a fresh ``WWW-Authenticate`` challenge that the agent needs to + # sign on the retry. The hook is contracted to return status=402 with the + # mppx-issued headers in this case; we propagate those into the rich 402. + mppx_headers: dict[str, str] = {} + if self.compose_mppx is not None: + try: + pre_composed = await _maybe_await(self.compose_mppx(ctx)) + if pre_composed.status == 402: + mppx_headers = dict(pre_composed.headers or {}) + except Exception: # noqa: S110 + # Hook errors here only affect the optional MPP challenge; the 402 + # still goes out with whatever rails resolved. Merchants log + # internally inside the hook itself, so we intentionally swallow. + pass + return await self._emit_402(ctx, mppx_headers=mppx_headers) + + def _invalid_body_envelope(self) -> dict[str, Any]: + """Canonical 400 ``invalid_body`` body. + + Framework-agnostic dict so per-framework adapters wrap it in their + native Response type. + """ + msg = "Request body must be valid JSON." + return build_validation_error( + code="invalid_body", + message=msg, + next_steps={"action": "fix_request", "user_message": msg}, + ) + + @staticmethod + def _extra_headers(headers: dict[str, str]) -> dict[str, str]: + """Strip ``Content-Type`` (case-insensitive); framework JSON helpers set it themselves.""" + return {k: v for k, v in headers.items() if k.lower() != "content-type"} + + async def handle_fastapi(self, request: Any, *, body: dict[str, Any] | None = None) -> Any: + """FastAPI / Starlette adapter; returns a ``JSONResponse``. + + Saves merchants from constructing :class:`CheckoutRequest` by hand and + wrapping the :class:`CheckoutResult` in a response. When ``body`` is not + provided, the adapter calls ``await request.json()``; pass a pre-parsed + pydantic dump when the route already validated the body shape. + + Compatible with ``Checkout(gate=...)``; the request is passed through + as ``CheckoutRequest.raw`` so the gate operates on it. + """ + from fastapi.responses import JSONResponse + + if body is None: + try: + parsed_body = await request.json() + except (ValueError, TypeError): + return JSONResponse(status_code=400, content=self._invalid_body_envelope()) + else: + parsed_body = body + result = await self.handle( + CheckoutRequest( + method=request.method, + url=str(request.url), + headers=dict(request.headers), + body=parsed_body, + assess=None, + raw=request, + ), + ) + return JSONResponse( + content=result.body, + status_code=result.status, + headers=self._extra_headers(result.headers), + ) + + # Alias: FastAPI's Request inherits from Starlette's; one adapter covers both. + handle_starlette = handle_fastapi + + async def handle_aiohttp(self, request: Any, *, body: dict[str, Any] | None = None) -> Any: + """Aiohttp adapter; returns ``aiohttp.web.Response``. + + Uses ``await request.json()`` for body parsing when ``body`` isn't supplied; + passes the native ``aiohttp.web.Request`` through as ``CheckoutRequest.raw``. + """ + from aiohttp import web + + if body is None: + try: + parsed_body = await request.json() + except (ValueError, TypeError): + return web.json_response(self._invalid_body_envelope(), status=400) + else: + parsed_body = body + result = await self.handle( + CheckoutRequest( + method=request.method, + url=str(request.url), + headers=dict(request.headers), + body=parsed_body, + assess=None, + raw=request, + ), + ) + return web.json_response(result.body, status=result.status, headers=self._extra_headers(result.headers)) + + async def handle_sanic(self, request: Any, *, body: dict[str, Any] | None = None) -> Any: + """Sanic adapter; returns ``sanic.response.HTTPResponse``. + + Sanic exposes ``request.json`` as a sync property (already-parsed). Pass + ``body=`` to skip the property read. + """ + from sanic.response import json as sanic_json + + if body is None: + try: + parsed_body = request.json or {} + except Exception: + return sanic_json(self._invalid_body_envelope(), status=400) + else: + parsed_body = body + result = await self.handle( + CheckoutRequest( + method=request.method, + url=str(request.url), + headers=dict(request.headers), + body=parsed_body, + assess=None, + raw=request, + ), + ) + return sanic_json(result.body, status=result.status, headers=self._extra_headers(result.headers)) + + def handle_flask(self, request: Any, *, body: dict[str, Any] | None = None) -> Any: + """Flask adapter; returns a ``flask.Response``. + + Flask is sync; this method bridges into the async :meth:`handle` via + :func:`asgiref.sync.async_to_sync`. Use inside a sync ``@app.route`` + handler, or call from an ``async def`` view in Flask 2.2+ (which uses + the same bridge internally). + """ + from asgiref.sync import async_to_sync + from flask import jsonify + + if body is None: + parsed_body = request.get_json(silent=True) + if parsed_body is None: + resp = jsonify(self._invalid_body_envelope()) + resp.status_code = 400 + return resp + else: + parsed_body = body + checkout_request = CheckoutRequest( + method=request.method, + url=request.url, + headers=dict(request.headers), + body=parsed_body, + assess=None, + raw=request, + ) + result = async_to_sync(self.handle)(checkout_request) + resp = jsonify(result.body) + resp.status_code = result.status + for k, v in self._extra_headers(result.headers).items(): + resp.headers[k] = v + return resp + + def handle_django(self, request: Any, *, body: dict[str, Any] | None = None) -> Any: + """Django adapter; returns a ``django.http.JsonResponse``. + + Django is sync (async views are supported but not assumed here); this + method bridges into the async :meth:`handle` via + :func:`asgiref.sync.async_to_sync`. + """ + import json as _json + + from asgiref.sync import async_to_sync + from django.http import JsonResponse + + if body is None: + try: + parsed_body = _json.loads(request.body) if request.body else {} + except (ValueError, TypeError): + return JsonResponse(self._invalid_body_envelope(), status=400) + else: + parsed_body = body + checkout_request = CheckoutRequest( + method=request.method, + url=request.build_absolute_uri(), + headers=dict(request.headers.items()), + body=parsed_body, + assess=None, + raw=request, + ) + result = async_to_sync(self.handle)(checkout_request) + return JsonResponse(result.body, status=result.status, headers=self._extra_headers(result.headers)) + + async def _run_gate(self, ctx: CheckoutContext) -> CheckoutResult | None: + """Run the per-request gate. + + Returns a denial CheckoutResult on hard denial; ``None`` on accept / + soft-unverified / anonymous (in which case ``ctx.assess`` is populated + with ``identity_status``). + + Three customization seams (in order of precedence): + + 1. ``gate.run_gate`` — when set, replaces the SDK's gate flow entirely. + 2. ``gate.per_request_policy`` — per-request policy override merged over + static gate fields. Return ``None`` to skip the gate. + 3. ``gate.on_denied`` — invoked after canonical DenialReason is built to + reshape the body for the merchant's response contract. + """ + if self.gate is None: + return None + + gate = self.gate + # 1. run_gate escape hatch — replaces everything else. + if gate.run_gate is not None: + result = await _maybe_await(gate.run_gate(ctx)) + return self._coerce_run_gate_result(ctx, result) + + # 2. per_request_policy resolves per-product compliance (e.g. wine vs + # generic merch). Return None to skip the gate entirely for this request. + policy: Any = None + if gate.per_request_policy is not None: + policy = await _maybe_await(gate.per_request_policy(ctx)) + if policy is None: + return None + + from agentscore_commerce.identity.policy import ( + build_gate_from_policy, + run_gate_with_enforcement, + ) + from agentscore_commerce.identity.sessions import CreateSessionOnMissing + + # Static gate fields land as the "base" policy; per_request_policy result + # merges over them so per-product hooks can refine compliance per call. + merged_policy: dict[str, Any] = {} + if gate.require_kyc is not None: + merged_policy["require_kyc"] = gate.require_kyc + if gate.require_sanctions_clear is not None: + merged_policy["require_sanctions_clear"] = gate.require_sanctions_clear + if gate.min_age is not None: + merged_policy["min_age"] = gate.min_age + if gate.blocked_jurisdictions is not None: + merged_policy["blocked_jurisdictions"] = gate.blocked_jurisdictions + if gate.allowed_jurisdictions is not None: + merged_policy["allowed_jurisdictions"] = gate.allowed_jurisdictions + if isinstance(policy, dict): + merged_policy.update(policy) + if not merged_policy: + merged_policy = {} + # `enforcement` is per-product (soft/hard); pull from the merged dict + # (per_request_policy is the only source) and remove before passing to + # build_gate_from_policy so it isn't treated as a policy field. + enforcement = merged_policy.pop("enforcement", None) if isinstance(merged_policy, dict) else None + + # Use the merchant-supplied CreateSessionOnMissing when provided; else + # auto-build one from the gate config so missing-identity denials still + # auto-mint a verify session. + session = gate.create_session_on_missing or CreateSessionOnMissing( + api_key=gate.api_key, + base_url=gate.base_url, + product_name=gate.merchant_name, + context=gate.context, + ) + gate_instance = build_gate_from_policy( + merged_policy or None, + api_key=gate.api_key, + base_url=gate.base_url, + create_session_on_missing=session, + ) + if ctx.request.raw is None: + msg = ( + "Checkout: gate=... requires CheckoutRequest.raw to be set to the " + "framework's native request object (today: FastAPI Request)." + ) + raise RuntimeError(msg) + result = await run_gate_with_enforcement( + ctx.request.raw, + gate_instance, + enforcement=enforcement, + ) + if result.status == "denied": + denial_body = result.denial_body or {} + denial_status = result.denial_status or 403 + # 3. on_denied callback — let merchants reshape the canonical body. + if gate.on_denied is not None: + custom = await _maybe_await(gate.on_denied(ctx, denial_body)) + if isinstance(custom, dict) and "body" in custom: + denial_body = custom.get("body") or denial_body + denial_status = custom.get("status", denial_status) + return CheckoutResult( + status=denial_status, + body=denial_body, + headers={}, + reference_id=ctx.reference_id, + settled=False, + settle_phase="gate_denied", + ) + # Stash ctx.capture_wallet so on_settled can bind the signer wallet to + # the operator credential without needing a framework-specific context. + # No-op when the request was wallet-authenticated (no operator_token). + operator_token = ctx.request.headers.get("x-operator-token") or ctx.request.headers.get("X-Operator-Token") + if operator_token: + self._set_capture_wallet(ctx, operator_token=operator_token, gate=gate) + + assess = dict(ctx.request.assess or {}) + assess["identity_status"] = result.status + ctx.request = CheckoutRequest( + method=ctx.request.method, + url=ctx.request.url, + headers=ctx.request.headers, + body=ctx.request.body, + assess=assess, + raw=ctx.request.raw, + ) + return None + + def _coerce_run_gate_result( + self, + ctx: CheckoutContext, + result: Any, + ) -> CheckoutResult | None: + """Map a `gate.run_gate` callback's return into a CheckoutResult or pass-through.""" + if result is None: + return None + if isinstance(result, dict): + return CheckoutResult( + status=int(result.get("status", 403)), + body=result.get("body", {}) or {}, + headers=result.get("headers") or {}, + reference_id=ctx.reference_id, + settled=False, + settle_phase="gate_denied", + ) + msg = "gate.run_gate must return None (allow) or a dict {status, body, headers?} (deny)" + raise TypeError(msg) + + def _set_capture_wallet( + self, + ctx: CheckoutContext, + *, + operator_token: str, + gate: CheckoutGateConfig, + ) -> None: + """Stash ``ctx.capture_wallet`` after a successful gate allow. + + Closes over the resolved operator_token + an AgentScoreCore-backed + client so ``on_settled`` can link the signer wallet without needing the + framework-specific request context. + """ + from agentscore_commerce.identity.core import AgentScoreCore + + async def _capture( + *, + wallet_address: str, + network: Literal["evm", "solana"], + idempotency_key: str | None = None, + ) -> None: + client = AgentScoreCore( + api_key=gate.api_key, + base_url=gate.base_url, + user_agent=gate.user_agent, + ) + await client.acapture_wallet( + operator_token=operator_token, + wallet_address=wallet_address, + network=network, + idempotency_key=idempotency_key, + ) + + ctx.capture_wallet = _capture + + async def _handle_zero_settle(self, ctx: CheckoutContext) -> CheckoutResult: + """Zero-amount carve-out: verify the credential, lift the signer, skip settle. + + CDP rejects EIP-3009 with value=0 (``invalid_payload``) and pympp's tempo + intents reject ``proof`` payloads; both refuse $0 settles outright. For + redemption flows that drop the amount to $0, we still want to: + + * authenticate the credential the agent submitted, + * capture the signer wallet so cross-merchant identity attaches, + * fire ``on_settled`` so the merchant can persist the order. + + Returns a 200 success path identical to a real settle, except + ``tx_hash`` is ``None``. + """ + if _has_x402_header(ctx.request.headers): + verified = await verify_x402_request( + headers=ctx.request.headers, + is_cached_address=self._async_is_cached_address, + accepted_network=self._x402_base_network or "", + ) + 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", + ) + carve = zero_amount_carve_out( + rail="x402-base", + payload=verified.payload if isinstance(verified.payload, dict) else None, + ) + outcome = SettleOutcome( + rail="x402", + rail_key=self._x402_rail_key(), + tx_hash=None, + signer_address=carve.signer_address, + signer_network="evm" if carve.signer_address else None, + payment_response_header=None, + raw=verified, + ) + return await self._build_success(ctx, outcome) + # MPP $0 carve-out: parse the Authorization header to lift the signer. + carve = zero_amount_carve_out( + rail="tempo", + authorization_header=ctx.request.headers.get("authorization"), + ) + outcome = SettleOutcome( + rail="mpp", + rail_key=self._mpp_rail_key(), + tx_hash=None, + signer_address=carve.signer_address, + signer_network="evm" if carve.signer_address else None, + payment_response_header=None, + raw=None, + ) + return await self._build_success(ctx, outcome) async def _async_is_cached_address(self, addr: str) -> bool: if self.is_cached_address is None: @@ -359,7 +1186,11 @@ async def _mint_reference_id(self, request: CheckoutRequest) -> str: async def _resolve_recipients(self, ctx: CheckoutContext) -> dict[str, str]: if self.mint_recipients is None: - return {} + return ctx.recipients + # Idempotent: if a prior call (e.g. pre-compose on the discovery leg) + # already minted, skip — re-running would mint fresh Stripe PIs / etc. + if ctx.recipients: + return ctx.recipients ctx.recipients = dict(await _maybe_await(self.mint_recipients(ctx))) return ctx.recipients @@ -381,13 +1212,14 @@ async def _handle_x402(self, ctx: CheckoutContext) -> CheckoutResult: settled=False, settle_phase="verify_failed", ) + x402_srv = await self._get_x402_server() settle = await process_x402_settle( - x402_server=self.x402_server, + x402_server=x402_srv, payload=verified.payload, resource_config={ "scheme": "exact", "network": verified.signed_network, - "price": f"${ctx.pricing.amount_usd}", + "price": f"${ctx.pricing.amount_usd:.2f}", "payTo": verified.signed_pay_to, "maxTimeoutSeconds": 300, }, @@ -398,6 +1230,25 @@ async def _handle_x402(self, ctx: CheckoutContext) -> CheckoutResult: }, ) if not isinstance(settle, ProcessX402SettleSuccess): + # Map each failure phase to its canonical merchant-facing response: + # verify_failed → 400 payment_proof_invalid, facilitator_error / + # settle_failed → 503 payment_provider_unavailable, etc. + classified = classify_x402_settle_result(settle) + response_headers = ( + {"Cache-Control": "no-store"} if classified is not None and classified.status >= 500 else {} + ) + if classified is not None: + return CheckoutResult( + status=classified.status, + body={ + "error": {"code": classified.code, "message": classified.message}, + "next_steps": classified.next_steps, + }, + headers=response_headers, + reference_id=ctx.reference_id, + settled=False, + settle_phase=settle.phase or "settle_failed", + ) return CheckoutResult( status=400, body=build_validation_error( @@ -411,8 +1262,25 @@ async def _handle_x402(self, ctx: CheckoutContext) -> CheckoutResult: settled=False, settle_phase=settle.phase or "settle_failed", ) + # Lift the on-chain tx hash from x402 2.9's typed SettleResponse so + # merchants don't have to dig into raw.settle_result.transaction. + x402_tx_hash: str | None = None + settle_obj = getattr(settle, "settle_result", None) + if settle_obj is not None: + x402_tx_hash = getattr(settle_obj, "transaction", None) or getattr(settle_obj, "tx_hash", None) + # The signer is the EIP-3009 ``payload.authorization.from``; extract + # via the SDK helper so address normalization is consistent. + from agentscore_commerce.payment.signer import extract_payment_signer, read_x402_payment_header + + x402_signer = extract_payment_signer( + read_x402_payment_header(ctx.request.headers), + ) outcome = SettleOutcome( rail="x402", + rail_key=self._x402_rail_key(), + tx_hash=x402_tx_hash, + signer_address=x402_signer.address if x402_signer else None, + signer_network=x402_signer.network if x402_signer else None, payment_response_header=settle.payment_response_header, raw=settle, ) @@ -426,11 +1294,31 @@ async def _handle_mppx(self, ctx: CheckoutContext) -> CheckoutResult: if composed.status == 200: outcome = SettleOutcome( rail="mpp", + rail_key=composed.rail_key, + tx_hash=composed.tx_hash, + signer_address=composed.signer_address, + signer_network=composed.signer_network, 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) + # _handle_mppx is only invoked when an ``Authorization: Payment`` header + # was present, so a 402 here means mppx REJECTED the credential. Surface + # as 400 ``payment_proof_invalid`` (the canonical "regenerate the + # credential" denial), echoing mppx's fresh WWW-Authenticate so the + # agent's retry signs against the new directive id. + return CheckoutResult( + status=400, + body=build_validation_error( + code="payment_proof_invalid", + message="MPP credential rejected; regenerate from a fresh 402 challenge.", + next_steps={"action": "regenerate_payment_credential"}, + ), + headers=dict(composed.headers or {}), + reference_id=ctx.reference_id, + settled=False, + settle_phase="verify_failed", + ) async def _emit_402( self, @@ -457,41 +1345,69 @@ async def _emit_402( 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), + total_usd=f"{ctx.pricing.amount_usd:.2f}", 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, ) + # Build x402 accepts BEFORE the body so they appear both in the rich body + # (agents read JSON) AND in the PAYMENT-REQUIRED header (x402-spec clients). + x402_accepts: list[Any] = [] + x402_resource: dict[str, str] | None = None + x402_network = self._x402_base_network + if self._x402_server_available() and x402_network: + from agentscore_commerce.payment.x402_server import build_x402_accepts_for_402 + + base_spec = next( + (spec for spec in emit_rails.values() if isinstance(spec, X402BaseRailSpec)), + None, + ) + if base_spec is not None: + recipient = await _resolve_recipient_value(base_spec.recipient) + try: + x402_srv = await self._get_x402_server() + x402_accepts = list( + build_x402_accepts_for_402( + x402_srv, + network=x402_network, + price=f"${ctx.pricing.amount_usd:.2f}", + pay_to=recipient, + max_timeout_seconds=300, + ) + ) + x402_resource = {"url": ctx.request.url, "mimeType": "application/json"} + except Exception: + # Facilitator/scheme build failure: drop x402 from accepts but + # keep other rails in the body. Merchant logs internally. + x402_accepts = [] + 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), + amount_usd=f"{ctx.pricing.amount_usd:.2f}", retry_body=ctx.request.body, agent_memory=first_encounter_agent_memory(first_encounter=True), + product=ctx.pricing.product, + extra=ctx.pricing.body_extras, + x402=X402PaymentRequired( + version=2, + accepts=x402_accepts, + extensions=self.discovery_extensions or None, + ) + if x402_accepts + else None, ) 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"}, - } + if x402_accepts: + x402_kwargs = { + "x402_version": 2, + "accepts": x402_accepts, + "resource": x402_resource, + } respond = respond_402( mppx_challenge_headers=mppx_headers or {}, @@ -526,6 +1442,128 @@ async def _build_success(self, ctx: CheckoutContext, outcome: SettleOutcome) -> ) +def format_pydantic_errors(err: Any) -> str: + """Render a pydantic ``ValidationError`` as a clean agent-readable summary. + + Default ``str(err)`` leaks pydantic.dev URLs and library version into the + response, which agents shouldn't see. Returns ``": ; ..."`` joined + with semicolons. Accepts any object with a callable ``.errors()`` method + returning ``[{"loc": (...), "msg": ...}, ...]``. + """ + errors_fn = getattr(err, "errors", None) + if not callable(errors_fn): + return str(err) + parts: list[str] = [] + for e in errors_fn(): + loc = ".".join(str(p) for p in e.get("loc", ())) or "body" + parts.append(f"{loc}: {e.get('msg', '')}") + return "; ".join(parts) + + +def validation_envelope( + *, + code: str, + message: str, + action: str = "fix_request", + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Framework-neutral 4xx envelope (``{error, next_steps, agent_instructions}``). + + Returns the body dict; merchants wrap in their framework's JSON response. + The per-framework :func:`validation_response_*` helpers do this for you. + """ + return build_validation_error( + code=code, + message=message, + next_steps={"action": action, "user_message": message}, + extra=extra, + ) + + +def validation_response_fastapi( + *, + code: str, + message: str, + action: str = "fix_request", + status: int = 400, + extra: dict[str, Any] | None = None, +) -> Any: + """FastAPI / Starlette one-liner for the canonical 4xx envelope.""" + from fastapi.responses import JSONResponse + + return JSONResponse( + content=validation_envelope(code=code, message=message, action=action, extra=extra), + status_code=status, + ) + + +def validation_response_flask( + *, + code: str, + message: str, + action: str = "fix_request", + status: int = 400, + extra: dict[str, Any] | None = None, +) -> Any: + """Flask one-liner; returns a ``flask.Response``.""" + from flask import jsonify + + resp = jsonify(validation_envelope(code=code, message=message, action=action, extra=extra)) + resp.status_code = status + return resp + + +def validation_response_django( + *, + code: str, + message: str, + action: str = "fix_request", + status: int = 400, + extra: dict[str, Any] | None = None, +) -> Any: + """Django one-liner; returns a ``django.http.JsonResponse``.""" + from django.http import JsonResponse + + return JsonResponse( + validation_envelope(code=code, message=message, action=action, extra=extra), + status=status, + ) + + +def validation_response_aiohttp( + *, + code: str, + message: str, + action: str = "fix_request", + status: int = 400, + extra: dict[str, Any] | None = None, +) -> Any: + """Aiohttp one-liner; returns an ``aiohttp.web.Response``.""" + from aiohttp import web + + return web.json_response( + validation_envelope(code=code, message=message, action=action, extra=extra), + status=status, + ) + + +def validation_response_sanic( + *, + code: str, + message: str, + action: str = "fix_request", + status: int = 400, + extra: dict[str, Any] | None = None, +) -> Any: + """Sanic one-liner; returns a ``sanic.response.HTTPResponse``.""" + from sanic.response import json as sanic_json + + return sanic_json( + validation_envelope(code=code, message=message, action=action, extra=extra), + status=status, + ) + + async def _resolve_recipient_value(r: RecipientLike) -> str: from agentscore_commerce.payment.rail_spec import resolve_recipient @@ -545,19 +1583,31 @@ def _apply_recipient_overrides( """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``). + passed through unchanged (no on-chain recipient; they use ``profile_id``). + + Drop-empty: when a merchant declares rails with sentinel empty-string + recipients (the per-order-mint pattern — e.g. Stripe-multichain merchants + that mint a fresh deposit address per request) and ``mint_recipients`` only + returns addresses for some rails, drop rails that resolve to an empty + recipient — those weren't actually minted for this request and shouldn't be + advertised in the 402. """ - if not overrides: - return rails + from dataclasses import replace + out: dict[str, CheckoutRailSpec] = {} for key, spec in rails.items(): - override = overrides.get(key) - if override is None or isinstance(spec, StripeRailSpec): + if isinstance(spec, StripeRailSpec): out[key] = spec continue - from dataclasses import replace - - out[key] = replace(spec, recipient=override) + override = overrides.get(key) + spec_recipient = getattr(spec, "recipient", None) + final_recipient = override if override is not None else spec_recipient + if final_recipient is None or final_recipient == "": + continue + if override is not None: + out[key] = replace(spec, recipient=override) + else: + out[key] = spec return out diff --git a/agentscore_commerce/checkout_hooks.py b/agentscore_commerce/checkout_hooks.py new file mode 100644 index 0000000..67d47d5 --- /dev/null +++ b/agentscore_commerce/checkout_hooks.py @@ -0,0 +1,107 @@ +"""Canonical Checkout hook implementations for the common merchant patterns. + +The hand-written ``compose_mppx`` / signer-extraction / etc. boilerplate every +merchant otherwise repeats; collapsed into ready-to-use factories that wrap +the underlying SDK helpers. Merchants compose them into ``Checkout(...)`` +instead of writing 25-line closures. + +These are deliberately small, focused factories. Anything more opinionated +(e.g. "what should on_settled do for goods sellers vs API sellers") stays +merchant-side; Checkout's hooks are the boundary, not the business logic. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from agentscore_commerce.checkout import MppxComposeOutcome +from agentscore_commerce.identity.address import normalize_address +from agentscore_commerce.payment.signer import extract_payment_signer + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from agentscore_commerce.checkout import CheckoutContext, ComposeMppxFn + + +def make_mppx_compose_hook( + *, + server_getter: Callable[[], Awaitable[Any]], +) -> ComposeMppxFn: + """Return the canonical ``compose_mppx`` hook for pympp-backed MPP rails. + + The hook: + + * Lazily resolves the pympp ``Mpp`` server via the supplied ``server_getter`` + (typically the output of :func:`lazy_mppx_server`). + * Forwards the request's ``Authorization: Payment`` header (or ``None`` on + the discovery leg) and the current pricing amount to ``mpp.charge``. + * Maps pympp's three outcomes to :class:`MppxComposeOutcome`: + + - ``Challenge`` (no/invalid credential) → ``status=402`` with the + ``www-authenticate`` header pympp issued. + - ``(Credential, Receipt)`` tuple → ``status=200`` with the tx hash + lifted from ``receipt.reference``/``receipt.transaction`` and the + signer lifted from the credential's ``did:pkh:...`` source. + - Any unexpected exception (pympp internal error) → ``status=402`` + (no headers; Checkout falls back to its standard 402 emit). + + Stripe SPT and Solana MPP can use the same hook; the ``Mpp`` instance is + rail-agnostic. For multi-intent setups, build a separate hook per ``Mpp`` + and dispatch by the merchant's own routing logic. + """ + + async def hook(ctx: CheckoutContext) -> MppxComposeOutcome: + if ctx.pricing is None: + return MppxComposeOutcome(status=402) + mpp = await server_getter() + authorization = ctx.request.headers.get("authorization") + amount_str = f"{ctx.pricing.amount_usd:.2f}" + try: + result = await mpp.charge(authorization=authorization, amount=amount_str) + except Exception: + return MppxComposeOutcome(status=402) + + if not isinstance(result, tuple): + to_www = getattr(result, "to_www_authenticate", None) + realm = getattr(mpp, "realm", "") + headers: dict[str, str] = {"www-authenticate": to_www(realm)} if callable(to_www) else {} + return MppxComposeOutcome(status=402, headers=headers) + + credential, receipt = result + tx_hash = getattr(receipt, "reference", None) or getattr(receipt, "transaction", None) + signer_address: str | None = None + signer_network: str | None = None + cred_source = getattr(credential, "source", None) + if isinstance(cred_source, str): + signer = extract_payment_signer(authorization_header=f"Payment {cred_source}") + if signer is None: + # `extract_payment_signer` expects a base64'd credential, not + # a raw DID; fall back to parsing the DID directly when pympp + # gives us the typed source string. + parts = cred_source.split(":") + if len(parts) >= 4 and parts[0] == "did" and parts[1] == "pkh": + family = parts[2] + addr = parts[-1] + if family == "eip155": + signer_address = normalize_address(addr) + signer_network = "evm" + elif family == "solana": + signer_address = normalize_address(addr) + signer_network = "solana" + else: + signer_address = signer.address + signer_network = signer.network + + return MppxComposeOutcome( + status=200, + tx_hash=tx_hash, + signer_address=signer_address, + signer_network=signer_network, + raw={"credential": credential, "receipt": receipt}, + ) + + return hook + + +__all__ = ["make_mppx_compose_hook"] diff --git a/agentscore_commerce/discovery/__init__.py b/agentscore_commerce/discovery/__init__.py index e1d1789..fef765b 100644 --- a/agentscore_commerce/discovery/__init__.py +++ b/agentscore_commerce/discovery/__init__.py @@ -1,5 +1,14 @@ """Discovery helpers — probe responder, Bazaar payload builder, .well-known/mpp.json, llms.txt, OpenAPI snippets.""" +from agentscore_commerce.discovery.agentscore_content import ( + PURCHASE_MODE_NOTES, + PurchaseMode, + build_agentscore_onboarding_steps, + build_merchant_index_json, + build_success_next_steps, + purchase_mode_note, + standard_endpoint_descriptions, +) from agentscore_commerce.discovery.bazaar import build_bazaar_discovery_payload from agentscore_commerce.discovery.llms_txt import ( LlmsTxtSection, @@ -18,6 +27,8 @@ siwx_security_scheme, x_guidance_extension, x_payment_info_extension, + x_payment_info_from_checkout, + x_service_info_extension, ) from agentscore_commerce.discovery.probe import ( DiscoveryProbeResponse, @@ -26,6 +37,7 @@ is_discovery_probe_request, sample_x402_accept_for_network, ) +from agentscore_commerce.discovery.redemption_md import build_redemption_skill_md from agentscore_commerce.discovery.robots_tag import ( DEFAULT_DISCOVERY_PATHS, DEFAULT_ROBOTS_TAG, @@ -43,6 +55,16 @@ build_skill_md, compatible_clients_by_rails, ) +from agentscore_commerce.discovery.well_known import ( + SignedDiscoveryResponse, + WellKnownPreflightResponse, + bootstrap_ucp_signing_key, + build_signed_jwks_response, + build_signed_ucp_response, + default_a2a_services, + well_known_cors_preflight_headers, + well_known_preflight_response, +) from agentscore_commerce.discovery.well_known_mpp import ( PaymentMethodConfig, build_well_known_mpp, @@ -55,16 +77,20 @@ __all__ = [ "DEFAULT_DISCOVERY_PATHS", "DEFAULT_ROBOTS_TAG", + "PURCHASE_MODE_NOTES", "DiscoveryProbeResponse", "DjangoNoindexMiddleware", "LlmsTxtSection", "NoindexNonDiscoveryMiddleware", "PaymentMethodConfig", + "PurchaseMode", "RailKey", + "SignedDiscoveryResponse", "SkillMdEndpoint", "SkillMdIdentityRequirements", "SkillMdLink", "SkillMdShippingPolicy", + "WellKnownPreflightResponse", "WellKnownX402Resource", "X402SampleProbe", "XPaymentInfoDynamicPrice", @@ -74,20 +100,34 @@ "agentscore_openapi_snippets", "agentscore_payment_required_schema", "agentscore_security_schemes", + "bootstrap_ucp_signing_key", + "build_agentscore_onboarding_steps", "build_bazaar_discovery_payload", "build_discovery_probe_response", "build_llms_txt", + "build_merchant_index_json", + "build_redemption_skill_md", + "build_signed_jwks_response", + "build_signed_ucp_response", "build_skill_md", + "build_success_next_steps", "build_well_known_mpp", "build_well_known_x402", "compatible_clients_by_rails", + "default_a2a_services", "install_flask_noindex", "is_discovery_path", "is_discovery_probe_request", "llms_txt_identity_section", "llms_txt_payment_section", + "purchase_mode_note", "sample_x402_accept_for_network", "siwx_security_scheme", + "standard_endpoint_descriptions", + "well_known_cors_preflight_headers", + "well_known_preflight_response", "x_guidance_extension", "x_payment_info_extension", + "x_payment_info_from_checkout", + "x_service_info_extension", ] diff --git a/agentscore_commerce/discovery/agentscore_content.py b/agentscore_commerce/discovery/agentscore_content.py new file mode 100644 index 0000000..4e6cfd5 --- /dev/null +++ b/agentscore_commerce/discovery/agentscore_content.py @@ -0,0 +1,289 @@ +"""Standard agent-facing prose for AgentScore-gated merchants. + +Every AgentScore merchant emits roughly the same skill.md onboarding steps, +catalog purchase-mode notes, and endpoint descriptions. These helpers ship +those canonical strings so merchants supply only the merchant-specific parts +(name, URL, accepted rails) and get consistent agent-facing content back. + +Rationale: agents that hit one AgentScore merchant should see the same pattern +hints at every other one. Custom prose per merchant adds noise without adding +information; the SDK owns the cross-merchant boilerplate so it stays consistent. +""" + +from __future__ import annotations + +from typing import Any, Final, Literal + +# Whether a paid surface accepts redemption codes. Applies to any merchant +# that bills per-purchase or per-call — goods (catalog rows) and API +# (per-endpoint or per-tier billing) both use this enum. + +PurchaseMode = Literal["redemption_only", "coupon_applicable", "paid_only"] + + +PURCHASE_MODE_NOTES: Final[dict[str, str]] = { + "redemption_only": ( + "Requires a single-use redemption code (printed on a mailer or other " + "out-of-band delivery). Submit the code in the request body as " + "`redemption_code`. Without a valid code the order is rejected." + ), + "coupon_applicable": ( + "Codes are optional. Without one, settle at list price. With a valid " + "code the discount is applied automatically (percent_off, fixed_off, " + "or fixed_settle)." + ), + "paid_only": ( + "Codes are NOT accepted. Settle at the listed price. Submitting a " + "`redemption_code` field returns 400 codes_not_accepted." + ), +} + + +def purchase_mode_note(mode: str) -> str: + """Return the canonical agent-facing note for a ``purchase_mode``. + + Falls back to an empty string for unknown modes so responses don't leak + ``None`` when the merchant introduces a non-standard mode. + """ + return PURCHASE_MODE_NOTES.get(mode, "") + + +def build_agentscore_onboarding_steps( + *, + merchant_name: str, + app_url: str, + accepted_rails: list[str], + requires_kyc: bool = False, + vendor_type: Literal["goods", "api"] = "goods", +) -> list[str]: + """Build the canonical skill.md ``onboarding_steps`` for an AgentScore merchant. + + Returns a list of imperative step strings the agent follows to bootstrap + wallet + Passport, then either browse + buy (goods) or make the paid call + (api). Generic across every AgentScore-gated merchant; only the + merchant_name + app_url + rails list are substituted in. + + Rails accepted today: ``"tempo"``, ``"x402-base"``, ``"solana-mpp"``, + ``"stripe-spt"``. Unknown rail names are passed through verbatim so future + rails work without an SDK bump. + + Pass ``vendor_type="api"`` for per-call API providers — the catalog step is + dropped and the final step becomes "Make the paid call" instead of + "Place the order". + """ + rail_word_map = { + "tempo": "Tempo USDC", + "x402-base": "x402 USDC on Base", + "solana-mpp": "Solana SPL USDC", + "stripe-spt": "Stripe Shared Payment Token", + } + rails_human = ", ".join(rail_word_map.get(r, r) for r in accepted_rails) + chain_flags = ( + " | ".join( + flag + for rail, flag in ( + ("tempo", "tempo"), + ("x402-base", "base"), + ("solana-mpp", "solana"), + ) + if rail in accepted_rails + ) + or "tempo|base" + ) + + # Per-rail compatible-client hints; mirrors `compatible_clients_by_rails` + # on the 402 body so skill.md and the runtime challenge stay in sync. + compatible_hints = [ + ("tempo", "`tempo request` works for tempo USDC.e"), + ("x402-base", "`x402-proxy` / `purl` work for Base x402"), + ("stripe-spt", "`@stripe/link-cli` works for Stripe SPT"), + ] + compatible_fragment = ", ".join(hint for rail, hint in compatible_hints if rail in accepted_rails) + + compatible_clients_clause = ( + f"the rails table also lists per-rail `compatible_clients` — {compatible_fragment}. " + if compatible_fragment + else "" + ) + install_step = ( + "Install agentscore-pay if you don't already have a compatible client for your funded chain: " + "`npm i -g @agent-score/pay` (or `brew install agentscore/tap/agentscore-pay`). " + f"{merchant_name} accepts: {rails_human}. agentscore-pay speaks every supported rail; " + f"{compatible_clients_clause}" + "Any spec-compliant client for an individual rail works too." + ) + bootstrap_step = ( + "First-run only: bootstrap wallet + Passport. Run `agentscore-pay agent-guide --json` " + "for the canonical cold-start path — it walks `agentscore-pay init` " + "(creates keystore + per-chain wallet), `agentscore-pay passport login` " + f"(one-time KYC{'; required for this merchant' if requires_kyc else ''}; " + "the human completes a verify URL once and pay caches the operator_token), " + "and `agentscore-pay balance` to see which chain has USDC. Skip if your " + "wallet+Passport are already provisioned." + ) + stripe_fallback_step = ( + "If your only payment method is a Stripe / Link card (no crypto), install `@stripe/link-cli` " + "instead of agentscore-pay and use it on the SPT rail. Identity gating still applies — the " + "merchant's 403 with `verify_url` lets you bootstrap a Passport even with no crypto wallet involved." + ) + returning_user_step = ( + "Returning user note: if you've paid an AgentScore-gated merchant before from this wallet, " + "the wallet is already in your Passport's `linked_wallets[]` and identity flows through " + "automatically with no re-KYC prompt. Paying from a NEW wallet while you already hold an " + "`opc_...` token returns 403 `wallet_signer_mismatch`; the body lists `linked_wallets[]` and " + "`agent_instructions.action: resign_or_switch_to_operator_token` with three deterministic " + "recoveries (switch to a linked wallet, drop the operator_token to re-KYC the new wallet, " + "or pre-claim the new wallet via SIWE on agentscore.sh/verify)." + ) + rail_count = len(accepted_rails) + rail_plural = "" if rail_count == 1 else "s" + pick_rail_step = ( + f"Pick the rail your wallet is funded for. The 402 advertises {rail_count} rail{rail_plural}. " + "`agentscore-pay balance` (without `--chain`) lists every chain's USDC; pay rejects with " + "`multi_rail_ambiguity` if you don't pass `--chain` on a multi-rail challenge." + ) + place_order_step = ( + f"Place the order: `agentscore-pay pay POST {app_url}/purchase --chain <{chain_flags}> " + "-d '' --max-spend ` for crypto rails. For Stripe SPT, follow the handoff " + "hint pay emits and use `@stripe/link-cli` instead. Either way pay handles the 402 retry, " + "signing, and Passport attachment; branch on the structured CliError `code` on non-zero " + "exit (insufficient_balance, multi_rail_ambiguity, config_error for missing wallet/Passport, etc.)." + ) + make_call_step = ( + f"Make the paid call: `agentscore-pay pay POST {app_url}/ --chain <{chain_flags}> " + "--max-spend `; pay handles 402 retry, rail selection, signing, and Passport " + "attachment. Branch on the structured CliError `code` on non-zero exit (insufficient_balance, " + "multi_rail_ambiguity, config_error for missing wallet/Passport, etc.)." + ) + + accepts_stripe = "stripe-spt" in accepted_rails + stripe_steps = [stripe_fallback_step] if accepts_stripe else [] + if vendor_type == "api": + return [ + install_step, + bootstrap_step, + *stripe_steps, + returning_user_step, + pick_rail_step, + make_call_step, + ] + return [ + install_step, + bootstrap_step, + *stripe_steps, + returning_user_step, + f"Browse the catalog: `curl {app_url}/catalog`.", + ( + "Read each product's `purchase_mode` and `purchase_note` to decide " + "whether a redemption code is required, optional, or rejected." + ), + pick_rail_step, + place_order_step, + ] + + +def build_merchant_index_json( + *, + name: str, + description: str, + docs: dict[str, str], + endpoints: dict[str, str], + supported_rails: list[str], + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build the canonical AgentScore commerce ``/`` root discovery body. + + Works for both goods merchants (catalog + purchase + orders) and API + merchants (per-call paid endpoints) — ``endpoints`` and any + merchant-specific fields are passed through ``extra``. + + Common fields surfaced: ``name``, ``description``, ``docs``, ``endpoints``, + ``audience: "agents"``, ``supported_rails``. Pass ``extra`` for + merchant-specific additions: ``compliance`` for goods merchants, ``pricing`` + for API merchants, ``website`` for branded fronts. + + ``docs`` keys map to absolute URLs; pass whichever discovery surfaces this + merchant ships (``llms``, ``openapi``, ``skill_md``, ``mpp``, ``agent_card``, + ``ucp``, ``jwks``, ``redemption``, ...). + """ + body: dict[str, Any] = { + "name": name, + "description": description, + "docs": docs, + "endpoints": endpoints, + "audience": "agents", + "supported_rails": supported_rails, + } + if extra: + body.update(extra) + return body + + +def standard_endpoint_descriptions(*, include_order_status_route: bool = False) -> dict[str, str]: + """Canonical descriptions for the standard AgentScore commerce endpoints. + + Use in ``/`` discovery JSON, OpenAPI summaries, or anywhere the merchant + needs to describe what each endpoint does in agent-readable language. + + Descriptions are merchant-agnostic — they describe the response semantics + (402 on discovery, 400 on validation, 403 on identity, 200 on success), not + the body schema (which varies per merchant; surface that in OpenAPI). + + Pass ``include_order_status_route=True`` for merchants that ship the + lightweight ``/orders/{id}/status`` PII-free variant alongside the full + ``/orders/{id}``. + """ + out: dict[str, str] = { + "GET /catalog": "List purchasable products.", + "GET /catalog/{slug}": "Single product detail.", + "POST /purchase": ( + "Place an order. Returns 402 on the discovery leg with payment " + "rails; 400 on body rejection; 403 + recovery payload when identity " + "is required; 200 with order confirmation on success." + ), + "GET /orders/{id}": "Order detail (PII). Identity-scoped.", + } + if include_order_status_route: + out["GET /orders/{id}/status"] = "Payment status only (no PII)." + return out + + +def build_success_next_steps( + *, + order_status_url: str | None = None, + fulfillment_eta: str | None = None, + user_message: str | None = None, +) -> dict[str, str]: + """Standard ``next_steps`` block emitted in a 200 success body. + + Works for both goods-merchant order-success and API-merchant per-call-success + — the ``user_message`` reinforces the cross-merchant Passport pattern + (universal), with merchant-specific copy overridable via ``user_message``. + + ``order_status_url`` is emitted as ``order_status_url``. API merchants that + don't have an order-detail endpoint can pass a usage/dashboard URL or omit + the field. + + ``fulfillment_eta`` is goods-specific (shipping window) — omit for API or + digital-goods merchants. + """ + out: dict[str, str] = { + "action": "done", + "user_message": user_message + or ("Order complete. Your AgentScore Passport is now active across every AgentScore-gated merchant."), + } + if order_status_url: + out["order_status_url"] = order_status_url + if fulfillment_eta is not None: + out["fulfillment_eta"] = fulfillment_eta + return out + + +__all__ = [ + "PURCHASE_MODE_NOTES", + "PurchaseMode", + "build_agentscore_onboarding_steps", + "build_success_next_steps", + "purchase_mode_note", + "standard_endpoint_descriptions", +] diff --git a/agentscore_commerce/discovery/openapi.py b/agentscore_commerce/discovery/openapi.py index cdc46ca..a7e1030 100644 --- a/agentscore_commerce/discovery/openapi.py +++ b/agentscore_commerce/discovery/openapi.py @@ -82,18 +82,24 @@ def x_payment_info_extension( *, price: XPaymentInfoPrice, protocols: list[dict[str, Any]], + description: str | None = None, ) -> dict[str, Any]: """Wrap a price + protocols block under ``x-payment-info``. For spreading into an OpenAPI operation object. ``protocols`` is a list of single-key dicts: ``{"x402": {}}`` for x402, ``{"mpp": {"method": ..., "intent": ..., "currency": ...}}`` for MPP. Order is preserved. + + Emits ``authMode: "payment"`` by default per the x402scan convention. """ if isinstance(price, XPaymentInfoFixedPrice): price_dict: dict[str, Any] = {"mode": "fixed", "currency": price.currency, "amount": price.amount} else: price_dict = {"mode": "dynamic", "currency": price.currency, "min": price.min, "max": price.max} - return {"x-payment-info": {"price": price_dict, "protocols": protocols}} + block: dict[str, Any] = {"authMode": "payment", "price": price_dict, "protocols": protocols} + if description is not None: + block["description"] = description + return {"x-payment-info": block} def x_guidance_extension(text: str) -> dict[str, str]: @@ -105,6 +111,87 @@ def x_guidance_extension(text: str) -> dict[str, str]: return {"x-guidance": text} +def x_service_info_extension( + *, + categories: list[str], + docs: dict[str, str] | None = None, +) -> dict[str, Any]: + """Wrap a service-info block under ``x-service-info``. + + Spread into the OpenAPI document's root alongside ``paths``, ``info``, etc. + Discovery crawlers (x402scan, agent CLIs) read this to categorize the + service and follow links to human-side docs. + """ + block: dict[str, Any] = {"categories": categories} + if docs is not None: + block["docs"] = docs + return {"x-service-info": block} + + +def x_payment_info_from_checkout( + *, + checkout: Any, + price: XPaymentInfoPrice, + description: str | None = None, + protocol_extras: dict[str, dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Derive an ``x-payment-info`` extension from a configured ``Checkout``. + + Walks ``checkout.rails`` and emits one entry in ``protocols[]`` per rail — + Tempo MPP, x402 (Base), Solana MPP, Stripe SPT. Saves merchants from + enumerating protocols by hand and keeps the OpenAPI doc in sync with the + actual rails the Checkout serves. + + ``price`` is merchant-supplied (the rail registry doesn't carry per-merchant + pricing). Per-rail extras (client commands) can be merged via + ``protocol_extras`` keyed by rail slug (``tempo``, ``base``, ``solana``, + ``stripe``). + + For Solana MPP, ``currency`` is the SPL mint address per the official + spec (paymentauth.org/draft-solana-charge-00) — read from ``spec.token``. + """ + from agentscore_commerce.payment.rail_spec import ( + SolanaMppRailSpec, + StripeRailSpec, + TempoRailSpec, + TempoSessionRailSpec, + X402BaseRailSpec, + ) + + protocols: list[dict[str, Any]] = [] + extras = protocol_extras or {} + for spec in checkout.rails.values(): + if isinstance(spec, StripeRailSpec): + entry = {"method": "stripe", "intent": "charge", "currency": "usd"} + entry.update(extras.get("stripe", {})) + protocols.append({"mpp": entry}) + elif isinstance(spec, X402BaseRailSpec): + entry = {"scheme": "exact", "network": "base", "asset": "USDC"} + entry.update(extras.get("base", {})) + protocols.append({"x402": entry}) + elif isinstance(spec, SolanaMppRailSpec): + token = getattr(spec, "token", None) + entry = {"method": "solana", "intent": "charge"} + if isinstance(token, str) and token: + entry["currency"] = token + entry.update(extras.get("solana", {})) + protocols.append({"mpp": entry}) + elif isinstance(spec, (TempoRailSpec, TempoSessionRailSpec)): + token = getattr(spec, "token", None) + currency = getattr(spec, "currency", None) + entry = {"method": "tempo", "intent": "charge"} + value = currency if isinstance(currency, str) and currency else token + if isinstance(value, str) and value: + entry["currency"] = value + entry.update(extras.get("tempo", {})) + protocols.append({"mpp": entry}) + return x_payment_info_extension( + price=price, + protocols=protocols, + description=description, + ) + + def agentscore_denial_schemas() -> dict[str, Any]: """Standard AgentScore denial response schemas for `components.schemas`.""" return { diff --git a/agentscore_commerce/discovery/redemption_md.py b/agentscore_commerce/discovery/redemption_md.py new file mode 100644 index 0000000..1be0f56 --- /dev/null +++ b/agentscore_commerce/discovery/redemption_md.py @@ -0,0 +1,135 @@ +"""Standard ``/redemption.md`` template for merchants offering printed-mailer redemption codes. + +Renders the canonical cold-start bootstrap section + TL;DR + recovery table + +body/code rules. Merchants supply only the merchant-specific bits (name, URL, +SKU intro, peer-merchant pointer) and the rest comes from this template. + +Mirrors the prose every AgentScore merchant otherwise hand-writes so agents +encounter the same shape of redemption flow at any merchant. +""" + +# ruff: noqa: E501 + +from __future__ import annotations + + +def build_redemption_skill_md( + *, + merchant_name: str, + app_url: str, + sku_intro: str | None = None, + peer_merchant_pointer: str | None = None, +) -> str: + """Render the canonical ``redemption.md`` for an AgentScore merchant. + + ``sku_intro`` is one paragraph describing what the code unlocks at this + merchant (e.g. "a wine SKU you'll find in /catalog with purchase_mode = + redemption_only"). Defaults to a generic placeholder. + + ``peer_merchant_pointer`` is the optional "Don't have a code?" cross-link + at the bottom; a URL or short markdown line pointing at the merchant-name's + sister non-code-only store. Omit to drop the section. + """ + sku_text = sku_intro or ( + "The code redeems a product at this merchant which you'll find in " + "/catalog with `purchase_mode = redemption_only`." + ) + + peer_section = "" + if peer_merchant_pointer: + peer_section = ( + "\n## Don't have a code?\n\n" + "This page is the redemption flow for printed-mailer codes. " + f"If you're looking to buy without a code, see: {peer_merchant_pointer}\n" + ) + + return f"""# Redeeming an AgentScore mailer code at {merchant_name} + +You're reading this because the human you're working for received a printed +AgentScore mailer with a single-use redemption code. This page tells you, the +agent, exactly how to turn that code into a shipped order. + +{sku_text} The 402 challenge on /purchase tells you the actual settle amount +after the code is applied; discounts can range from a partial amount off list +down to free. + +## Cold-start bootstrap (skip if your wallet + Passport are already set up) + +If `agentscore-pay` isn't installed yet, install it (`npm i -g @agent-score/pay` +or `brew install agentscore/tap/agentscore-pay`), then run `agentscore-pay +agent-guide --json` for the canonical cold-start path. That walks +`agentscore-pay init` (creates keystore + per-chain wallet), +`agentscore-pay passport login` (one-time KYC; opens a verify URL the human +completes, after which pay caches the `operator_token`), and +`agentscore-pay balance` to confirm funds. Fund enough to cover the +post-discount settle amount the 402 advertises; for $0 codes the merchant +skips the on-chain settle entirely so funds aren't required, but the wallet +still needs to exist so the credential can be signed. + +You don't have to use `agentscore-pay` specifically; any spec-compliant client +for the merchant's accepted rails (Tempo MPP, x402 Base, Solana MPP, Stripe SPT) +works. The 402 challenge lists every accepted rail in `accepted_methods`. + +## TL;DR + +1. Ask the user for their redemption code, email, and US shipping address. +2. `GET {app_url}/catalog`; find the product whose `purchase_mode` is + `redemption_only`. Read its `purchase_note` for any product-specific rules. +3. `POST {app_url}/purchase` with body: + ```json + {{ + "product_slug": "", + "redemption_code": "", + "email": "user@example.com", + "shipping": {{ "name": "...", "address_1": "...", "city": "...", "state": "CA", "zip": "94573" }} + }} + ``` +4. If you get **403 `operator_verification_required`**, surface the body's + `verify_url` to the user for one-time KYC and poll `poll_url` with + `poll_secret`. After verification, retry with `X-Operator-Token` attached. + If you already have an `opc_...` from a prior AgentScore-gated merchant, + attach it on the first call and skip this step. +5. On **402**, the body carries `accepted_methods` and `agent_instructions.how_to_pay`. + Settle with `agentscore-pay pay POST {app_url}/purchase --chain -d '' + --max-spend `; pay handles 402 retry, rail selection, signing, and + Passport attachment. Pass `--max-spend` ≥ the amount in the 402. +6. **200**; order confirmed. Response carries `order.id`, `next_steps.order_status_url`, + and an `agent_memory` block you should persist (the cross-merchant pattern hint, + NOT the operator_token or poll_secret). For $0 redemptions `tx_hash` is `null`; + the credential is still authenticated and the code is burned single-use. + +## Body rules + +- `quantity` is fixed at 1; one product per code. +- `shipping.country` defaults to `"US"`; non-US shipping is rejected for + redemption-eligible products. +- `shipping.state` must be a 2-letter US state code; `unsupported_jurisdiction` + 400 if the state isn't on the merchant's allowlist. +- `email` must be valid; FastAPI returns 422 on malformed input. + +## Code rules + +- Codes are case-insensitive (server uppercases on receipt), single-use, and + burned atomically against `(code, operator_token)` OR `(code, signer_address)` + for token-less wallet flows. A second attempt returns 400 `redemption_already_used`. +- Submit the code in the JSON body as `"redemption_code"`; never as a header. + +## Recovery on common errors + +| HTTP | error.code | What it means | What to do | +|---|---|---|---| +| 403 | `operator_verification_required` | User has no Passport / KYC pending | Surface `verify_url`; poll `poll_url` with `poll_secret`; retry with `X-Operator-Token` | +| 403 | `wallet_signer_mismatch` | Operator token + signer wallet aren't linked to the same identity | Switch to a wallet in `linked_wallets[]`, or drop the operator_token to re-KYC the new wallet | +| 400 | `invalid_body` | JSON parse failed | Fix the JSON and retry | +| 400 | `missing_fields` | Required field absent | Add the field per `error.message` and retry | +| 400 | `product_not_found` | `product_slug` doesn't match an active product | Re-check `/catalog` and use the exact slug | +| 400 | `product_out_of_stock` | Product real but stock 0 | Tell the user; no retry possible | +| 400 | `invalid_redemption_code` | Code unknown / expired | Ask the user for the code as printed; do not invent variants | +| 400 | `redemption_already_used` | Code burned | Tell the user; codes are single-use | +| 400 | `codes_not_accepted` | Product is `paid_only` and rejects codes | Drop `redemption_code` and retry, or pick a different product | +| 400 | `unsupported_jurisdiction` | Shipping state not on allowlist | Ask for an allowed shipping address | +| 402 | (challenge) | Identity OK; payment required | Run `agentscore-pay pay` against the same URL | +{peer_section}""" + + +__all__ = ["build_redemption_skill_md"] diff --git a/agentscore_commerce/discovery/well_known.py b/agentscore_commerce/discovery/well_known.py new file mode 100644 index 0000000..d520020 --- /dev/null +++ b/agentscore_commerce/discovery/well_known.py @@ -0,0 +1,335 @@ +"""Spec-rooted helpers for ``/.well-known/{ucp,jwks.json}`` discovery surfaces. + +What this module collapses for every UCP-publishing merchant: + +* Loading + caching the signing key via :func:`load_ucp_signing_key_from_env`. +* Composing the ``payment_handlers`` map from the merchant's :class:`Checkout` + rails (TempoRailSpec → mpp_payment_handler; X402BaseRailSpec → x402_payment_handler; + StripeRailSpec → stripe_spt_payment_handler). +* Building the unsigned profile + signing it. +* Cache-Control + CORS + X-Request-ID echo per UCP §6. +* RFC 7517 §8.5 ``application/jwk-set+json`` media type on JWKS. +* The 503 ``ucp_misconfigured`` fallback envelope when no handlers can be + derived (empty rails dict OR all rails have empty recipients). + +Each helper returns a framework-neutral :class:`SignedDiscoveryResponse` that +merchants wrap in their framework's Response builder (FastAPI ``Response``, +aiohttp ``web.Response``, Flask ``Response``, Django ``HttpResponse``, etc.). +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from agentscore_commerce.identity.ucp import ( + AgentScoreGatePolicy, + UCPServiceBinding, + UCPSigningKey, + build_ucp_profile, + mpp_payment_handler, + stripe_spt_payment_handler, + x402_payment_handler, +) +from agentscore_commerce.identity.ucp_jwks import ( + build_jwks_response, + load_ucp_signing_key_from_env, + sign_ucp_profile, +) +from agentscore_commerce.payment.rail_spec import ( + SolanaMppRailSpec, + StripeRailSpec, + TempoRailSpec, + TempoSessionRailSpec, + X402BaseRailSpec, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + + from agentscore_commerce.checkout import Checkout + +_UCP_CACHE_SECONDS = 60 +_JWKS_CACHE_SECONDS = 300 + + +@dataclass +class SignedDiscoveryResponse: + """Framework-neutral response shape for discovery endpoints. + + Wrap in your framework's response builder. The body is already JSON-encoded + bytes; do not re-serialize. + """ + + content: bytes + media_type: str + headers: dict[str, str] = field(default_factory=dict) + status: int = 200 + + +def _request_id(request_headers: Mapping[str, str] | None) -> str | None: + if request_headers is None: + return None + for key, value in request_headers.items(): + if key.lower() == "x-request-id": + return value + return None + + +def _attach_request_id(headers: dict[str, str], request_headers: Mapping[str, str] | None) -> None: + rid = _request_id(request_headers) + if rid is not None: + headers["X-Request-ID"] = rid + + +def _compose_handlers(checkout: Checkout) -> dict[str, list[Any]]: + """Map rails on the Checkout to a UCP ``payment_handlers`` block. + + Includes rails with empty-string-sentinel recipients (per-order-mint + pattern) — the static UCP profile drops the recipient field from those + entries, and the authoritative per-order recipient ships in the 402 body + at request time. Only rails missing the ``recipient`` attribute entirely + are excluded. + """ + handlers: dict[str, list[Any]] = {} + mpp: list[TempoRailSpec | SolanaMppRailSpec | TempoSessionRailSpec] = [] + x402: list[X402BaseRailSpec] = [] + stripe: list[StripeRailSpec] = [] + for spec in checkout.rails.values(): + if isinstance(spec, (TempoRailSpec, TempoSessionRailSpec, SolanaMppRailSpec)): + if hasattr(spec, "recipient"): + mpp.append(spec) + elif isinstance(spec, X402BaseRailSpec): + if hasattr(spec, "recipient"): + x402.append(spec) + elif isinstance(spec, StripeRailSpec): + stripe.append(spec) + if mpp: + handlers.update(mpp_payment_handler(networks=mpp)) + if x402: + handlers.update(x402_payment_handler(networks=x402)) + for spec in stripe: + handlers.update(stripe_spt_payment_handler(spec=spec)) + return handlers + + +def _misconfigured_response(request_headers: Mapping[str, str] | None) -> SignedDiscoveryResponse: + body = { + "error": { + "code": "ucp_misconfigured", + "message": "Merchant has no configured payment handlers.", + }, + "next_steps": { + "action": "contact_merchant", + "user_message": "This merchant is temporarily unable to accept agent payments.", + }, + "agent_instructions": { + "action": "contact_merchant", + "steps": [ + "Surface a transient error to the user.", + "Retry later; the merchant operator will repair the configuration.", + ], + "user_message": "Merchant temporarily offline for agent payments.", + }, + } + # UCP §6 forbids `no-store` on profile responses. 60s is the minimum cache age; + # short enough that recovery is fast once the merchant restores config. + headers: dict[str, str] = { + "Access-Control-Allow-Origin": "*", + "Cache-Control": f"public, max-age={_UCP_CACHE_SECONDS}", + } + _attach_request_id(headers, request_headers) + return SignedDiscoveryResponse( + content=json.dumps(body).encode(), + media_type="application/json", + headers=headers, + status=503, + ) + + +def build_signed_ucp_response( + *, + checkout: Checkout, + name: str, + well_known_ucp_url: str, + services: dict[str, list[UCPServiceBinding]], + request_headers: Mapping[str, str] | None = None, + signing_kid: str = "merchant-default", + agentscore_gate: AgentScoreGatePolicy | None = None, +) -> SignedDiscoveryResponse: + """Build the signed UCP profile response for ``/.well-known/ucp``. + + Composes payment handlers from the Checkout's rails dict, builds the + profile via :func:`build_ucp_profile`, signs via :func:`sign_ucp_profile`, + and attaches the UCP §6-prescribed Cache-Control + CORS + X-Request-ID + headers. + + Returns a 503 ``ucp_misconfigured`` envelope (still with the §6-compliant + Cache-Control) when no payment handlers can be derived from rails. + + ``services`` is the spec-compliant services map (keyed by reverse-DNS + service name). ``well_known_ucp_url`` is the canonical URL of this profile, + surfaced as the value in ``supported_versions``. + """ + handlers = _compose_handlers(checkout) + if not handlers: + return _misconfigured_response(request_headers) + + key = load_ucp_signing_key_from_env(default_kid=signing_kid) + signing_key_entry = UCPSigningKey.from_jwk(key.public_jwk) + + profile = build_ucp_profile( + name=name, + supported_versions={"2026-04-08": well_known_ucp_url}, + agentscore_gate=agentscore_gate, + services=services, + payment_handlers=handlers, + signing_keys=[signing_key_entry], + ) + signed = sign_ucp_profile( + profile.to_dict(), + signing_key=key.private_key, + kid=key.public_jwk["kid"], + alg=key.public_jwk.get("alg", "EdDSA"), + ) + headers: dict[str, str] = { + "Cache-Control": f"public, max-age={_UCP_CACHE_SECONDS}", + "Access-Control-Allow-Origin": "*", + } + _attach_request_id(headers, request_headers) + return SignedDiscoveryResponse( + content=json.dumps(signed).encode(), + media_type="application/json", + headers=headers, + ) + + +def build_signed_jwks_response( + *, + request_headers: Mapping[str, str] | None = None, + signing_kid: str = "merchant-default", +) -> SignedDiscoveryResponse: + """Build the JWKS response for ``/.well-known/jwks.json``. + + RFC 7517 §8.5 prescribes ``application/jwk-set+json``. Five-minute + Cache-Control balances verifier-side cache hit rate against rotation + propagation latency. + """ + key = load_ucp_signing_key_from_env(default_kid=signing_kid) + jwks = build_jwks_response([key.public_jwk]) + headers: dict[str, str] = { + "Cache-Control": f"public, max-age={_JWKS_CACHE_SECONDS}", + "Access-Control-Allow-Origin": "*", + } + _attach_request_id(headers, request_headers) + return SignedDiscoveryResponse( + content=json.dumps(jwks).encode(), + media_type="application/jwk-set+json", + headers=headers, + ) + + +def well_known_cors_preflight_headers( + request_headers: Mapping[str, str] | None = None, +) -> dict[str, str]: + """CORS preflight headers for ``/.well-known/*`` endpoints. + + Echoes ``Access-Control-Request-Headers`` verbatim when present rather + than advertising ``*`` (which browsers reject with credentials in scope). + Returns a 204 on the corresponding response via the merchant's framework. + """ + headers: dict[str, str] = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Max-Age": "86400", + "Vary": "Access-Control-Request-Headers", + } + if request_headers is not None: + for key, value in request_headers.items(): + if key.lower() == "access-control-request-headers": + headers["Access-Control-Allow-Headers"] = value + break + return headers + + +@dataclass +class WellKnownPreflightResponse: + """Framework-neutral 204 preflight result. + + Merchants wrap into their framework's response shape (FastAPI ``Response``, + Flask ``Response``, etc.). + """ + + status: int + headers: dict[str, str] + content: bytes = b"" + + +def well_known_preflight_response( + request_headers: Mapping[str, str] | None = None, +) -> WellKnownPreflightResponse: + """Build a 204 CORS preflight response for ``/.well-known/*`` endpoints. + + Wraps :func:`well_known_cors_preflight_headers`. Universal across every + UCP-publishing merchant; saves the 3-line ``Response(status_code=204, + headers=...)`` wrapper every consumer otherwise hand-rolls. + """ + return WellKnownPreflightResponse( + status=204, + headers=well_known_cors_preflight_headers(request_headers), + ) + + +_UCP_SHOPPING_SPEC_2026_04_08 = "https://ucp.dev/2026-04-08/specification/overview" + + +def default_a2a_services(*, agent_card_url: str) -> dict[str, list[UCPServiceBinding]]: + """Canonical UCP §services map for a merchant publishing an A2A agent card. + + Returns ``{"dev.ucp.shopping": [UCPServiceBinding(version="2026-04-08", + spec="", transport="a2a", endpoint=agent_card_url)]}`` ; + the binding every UCP-publishing merchant declares when their primary agent + surface is the A2A v1.0 ``/.well-known/agent-card.json`` (versus a UCP MCP + or REST endpoint). + + Merchants who additionally expose a UCP MCP or REST transport append further + bindings to the same ``dev.ucp.shopping`` list. + """ + return { + "dev.ucp.shopping": [ + UCPServiceBinding( + version="2026-04-08", + spec=_UCP_SHOPPING_SPEC_2026_04_08, + transport="a2a", + endpoint=agent_card_url, + ), + ], + } + + +def bootstrap_ucp_signing_key(*, default_kid: str = "merchant-default") -> None: + """Eager-load the UCP signing key at startup. + + A malformed ``UCP_SIGNING_KEY_JWK_PRIVATE`` env value otherwise surfaces + on the first ``/.well-known/ucp`` hit after deploy, masquerading as a + runtime 500. Calling this in the framework's startup / lifespan hook + fails the deploy fast. + + Wraps :func:`load_ucp_signing_key_from_env`; raises ``ValueError`` (per + that helper's contract) on a malformed JWK so the orchestrator marks the + task unhealthy. + """ + load_ucp_signing_key_from_env(default_kid=default_kid) + + +__all__ = [ + "SignedDiscoveryResponse", + "WellKnownPreflightResponse", + "bootstrap_ucp_signing_key", + "build_signed_jwks_response", + "build_signed_ucp_response", + "default_a2a_services", + "well_known_cors_preflight_headers", + "well_known_preflight_response", +] diff --git a/agentscore_commerce/identity/_denial.py b/agentscore_commerce/identity/_denial.py index 2fdcf4c..1556a36 100644 --- a/agentscore_commerce/identity/_denial.py +++ b/agentscore_commerce/identity/_denial.py @@ -18,7 +18,6 @@ """ from collections.abc import Iterable -from dataclasses import asdict from typing import Any from agentscore_commerce.identity.types import DenialReason, VerifyWalletSignerResult @@ -203,9 +202,3 @@ def verification_agent_instructions( "is_fixable_denial", "verification_agent_instructions", ] - - -# asdict re-exported for convenience when vendors need to serialize DenialReason directly -# (the gate adapters do this internally, but vendors writing custom on_denied handlers may -# need it for nested dataclass fields). -_ = asdict diff --git a/agentscore_commerce/identity/core.py b/agentscore_commerce/identity/core.py index 6ded865..f09445b 100644 --- a/agentscore_commerce/identity/core.py +++ b/agentscore_commerce/identity/core.py @@ -520,11 +520,6 @@ def _infer_signer_network(self, signer: str) -> str: return "evm" if signer.startswith("0x") else "solana" -# Re-export the timeout error class so adapters can recognize SDK-side timeouts -# without having to import it from the underlying SDK directly. -__all_sdk_timeout__ = SdkTimeoutError - - class PaymentRequiredError(Exception): """Raised when the AgentScore API returns 402.""" @@ -555,8 +550,6 @@ def __init__(self, body: dict[str, Any]) -> None: super().__init__("token_expired") self.code: Literal["token_expired"] = "token_expired" self.body: dict[str, Any] = body - # Legacy accessor for callers that read .next_steps directly. - self.next_steps = body.get("next_steps") if isinstance(body, dict) else None def build_token_denied_reason(err: TokenDeniedError) -> DenialReason: @@ -573,7 +566,7 @@ def build_token_denied_reason(err: TokenDeniedError) -> DenialReason: session_id=body.get("session_id") if isinstance(body.get("session_id"), str) else None, poll_secret=body.get("poll_secret") if isinstance(body.get("poll_secret"), str) else None, poll_url=body.get("poll_url") if isinstance(body.get("poll_url"), str) else None, - agent_instructions=json.dumps(err.next_steps) if err.next_steps else None, + agent_instructions=(json.dumps(body["next_steps"]) if isinstance(body.get("next_steps"), dict) else None), ) diff --git a/agentscore_commerce/identity/ucp.py b/agentscore_commerce/identity/ucp.py index abc2eea..08959c6 100644 --- a/agentscore_commerce/identity/ucp.py +++ b/agentscore_commerce/identity/ucp.py @@ -497,13 +497,13 @@ def _ucp_network_name(caip2_or_ucp: str) -> str: def _static_recipient(r: RecipientLike) -> str | None: - """Return the recipient as a string when it's already concrete; `None` for factories. + """Return the recipient as a non-empty string when it's concrete. - Per-order factory recipients (e.g. Stripe-multichain mints fresh deposits per - PaymentIntent) cannot be advertised in the static UCP profile — the authoritative - recipient ships in the 402 body at request time instead. + Returns ``None`` for factory callables OR empty-string sentinels (both + signal per-order minting; the authoritative recipient ships in the 402 + body at request time, not in the static UCP profile). """ - return r if isinstance(r, str) else None + return r if isinstance(r, str) and r else None def _tempo_to_network_entry(spec: TempoRailSpec) -> dict[str, Any]: diff --git a/agentscore_commerce/payment/__init__.py b/agentscore_commerce/payment/__init__.py index 917b872..e5a09fd 100644 --- a/agentscore_commerce/payment/__init__.py +++ b/agentscore_commerce/payment/__init__.py @@ -1,6 +1,6 @@ """Payment helpers — networks/usdc/rails registries, paymentauth.org directive builders, dispatch, headers.""" -from agentscore_commerce.payment.amounts import usd_to_atomic +from agentscore_commerce.payment.amounts import format_usd_cents, usd_to_atomic from agentscore_commerce.payment.directive import ( build_payment_directive, build_payment_request_blob, @@ -14,6 +14,7 @@ build_payment_headers, ) from agentscore_commerce.payment.idempotency import build_idempotency_key +from agentscore_commerce.payment.lazy import lazy_mppx_server, lazy_x402_server from agentscore_commerce.payment.mppx_server import MppxRailSpec, create_mppx_server from agentscore_commerce.payment.networks import NetworkFamily, network_family, networks from agentscore_commerce.payment.rail_spec import ( @@ -34,9 +35,11 @@ PaymentSigner, SignerNetwork, extract_payment_signer, + extract_signer_for_precheck, extract_x402_signer, read_x402_payment_header, ) +from agentscore_commerce.payment.solana import load_solana_fee_payer from agentscore_commerce.payment.usdc import USDC from agentscore_commerce.payment.wwwauthenticate import ( alias_amount_fields, @@ -122,7 +125,12 @@ "detect_rail_from_headers", "dispatch_settlement_by_network", "extract_payment_signer", + "extract_signer_for_precheck", "extract_x402_signer", + "format_usd_cents", + "lazy_mppx_server", + "lazy_x402_server", + "load_solana_fee_payer", "lookup_rail", "network_family", "networks", diff --git a/agentscore_commerce/payment/amounts.py b/agentscore_commerce/payment/amounts.py index 2d249fa..ee9d5eb 100644 --- a/agentscore_commerce/payment/amounts.py +++ b/agentscore_commerce/payment/amounts.py @@ -55,3 +55,14 @@ def usd_to_atomic(usd: str | float | int | Decimal, *, decimals: int) -> int: scaled = (amount * (Decimal(10) ** decimals)).to_integral_value(rounding=ROUND_HALF_UP) return int(scaled) + + +def format_usd_cents(cents: int) -> str: + """Format an integer cent amount as a fixed-2-decimal USD string. + + ``500`` → ``"5.00"``. Negative values are formatted with a leading minus. + Use everywhere a merchant emits ``f"{cents / 100:.2f}"`` today; consistent + formatting across catalog rows, order responses, and 402 bodies prevents + agent-side string-comparison flakiness. + """ + return f"{cents / 100:.2f}" diff --git a/agentscore_commerce/payment/lazy.py b/agentscore_commerce/payment/lazy.py new file mode 100644 index 0000000..d0021ec --- /dev/null +++ b/agentscore_commerce/payment/lazy.py @@ -0,0 +1,109 @@ +"""Lazy-init helpers for x402 + mppx servers. + +Every merchant accepting these rails writes the same singleton + asyncio.Lock +pattern around ``create_x402_server`` / ``create_mppx_server``. These helpers +collapse the boilerplate to a single call; the returned getter is safe to call +from any number of concurrent handlers; only one server instance is ever +constructed per merchant. + +The x402 helper also derives the facilitator choice (``coinbase`` vs ``http``) +from optional CDP credentials so merchants don't repeat the boot-time conditional. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +from agentscore_commerce.payment.mppx_server import create_mppx_server +from agentscore_commerce.payment.x402_server import create_x402_server + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from agentscore_commerce.payment.mppx_server import MppxRailSpec + from agentscore_commerce.payment.rail_spec import X402BaseRailSpec + from agentscore_commerce.payment.x402_server import X402SymbolicRail + + +def _x402_rail_name(spec: X402BaseRailSpec) -> X402SymbolicRail: + """Map ``X402BaseRailSpec.network`` to the symbolic rail string. + + The underlying x402 scheme registry indexes by symbolic name; we keep the + CAIP-2 ↔ symbolic mapping in one place so merchants pass RailSpecs everywhere. + """ + if spec.network in ("eip155:8453",): + return "x402-base-mainnet" + if spec.network in ("eip155:84532",): + return "x402-base-sepolia" + msg = f"lazy_x402_server: unsupported X402BaseRailSpec.network={spec.network!r}" + raise ValueError(msg) + + +def lazy_x402_server( + *, + spec: X402BaseRailSpec, + cdp_api_key_id: str | None = None, + cdp_api_key_secret: str | None = None, +) -> Callable[[], Awaitable[Any]]: + """Build a memoized async getter for an x402 server. + + First call constructs the server; subsequent calls return the cached + instance. Concurrent first-callers serialize on an asyncio.Lock so we + never construct two and discard one. + + When both CDP creds are passed, the server uses Coinbase's facilitator; + otherwise it falls back to the public HTTP facilitator. Merchants who + only have one of the two creds get the HTTP fallback (with a server-side + warning logged by ``create_x402_server``). + """ + cache: list[Any] = [None] + lock = asyncio.Lock() + rail_name = _x402_rail_name(spec) + use_cdp = bool(cdp_api_key_id and cdp_api_key_secret) + facilitator = "coinbase" if use_cdp else "http" + + async def getter() -> Any: + if cache[0] is not None: + return cache[0] + async with lock: + if cache[0] is not None: + return cache[0] + cache[0] = await create_x402_server(facilitator=facilitator, rails=[rail_name]) + return cache[0] + + return getter + + +def lazy_mppx_server( + *, + rails: dict[str, MppxRailSpec], + secret_key: str, + realm: str | None = None, +) -> Callable[[], Awaitable[Any]]: + """Build a memoized async getter for a pympp server. + + Same singleton + lock semantics as :func:`lazy_x402_server`. Forwards + ``rails`` / ``secret_key`` / ``realm`` unchanged to + :func:`create_mppx_server`. + """ + cache: list[Any] = [None] + lock = asyncio.Lock() + + async def getter() -> Any: + if cache[0] is not None: + return cache[0] + async with lock: + if cache[0] is not None: + return cache[0] + cache[0] = await create_mppx_server( + secret_key=secret_key, + rails=rails, + realm=realm, + ) + return cache[0] + + return getter + + +__all__ = ["lazy_mppx_server", "lazy_x402_server"] diff --git a/agentscore_commerce/payment/mppx_server.py b/agentscore_commerce/payment/mppx_server.py index 964f10d..93cba04 100644 --- a/agentscore_commerce/payment/mppx_server.py +++ b/agentscore_commerce/payment/mppx_server.py @@ -38,6 +38,7 @@ from agentscore_commerce.payment.rail_spec import ( RecipientLike, + SolanaMppRailSpec, StripeRailSpec, TempoRailSpec, TempoSessionRailSpec, @@ -45,7 +46,7 @@ ) from agentscore_commerce.payment.usdc import USDC -MppxRailSpec = TempoRailSpec | TempoSessionRailSpec | StripeRailSpec +MppxRailSpec = TempoRailSpec | TempoSessionRailSpec | StripeRailSpec | SolanaMppRailSpec def _import_optional(module_name: str) -> Any | None: diff --git a/agentscore_commerce/payment/rail_spec.py b/agentscore_commerce/payment/rail_spec.py index 8c2bcdd..6ebe7eb 100644 --- a/agentscore_commerce/payment/rail_spec.py +++ b/agentscore_commerce/payment/rail_spec.py @@ -1,4 +1,4 @@ -"""Canonical `*RailSpec` types — one shape per rail, consumed by every helper. +"""Canonical `*RailSpec` types; one shape per rail, consumed by every helper. A merchant accepting Tempo + Base + Solana + Stripe declares one `*RailSpec` per rail and passes it to every helper (`build_accepted_methods`, @@ -19,7 +19,6 @@ from dataclasses import dataclass, field from typing import Any, Literal, cast -from agentscore_commerce.payment.networks import networks from agentscore_commerce.payment.usdc import USDC RecipientLike = str | Callable[[], Awaitable[str]] | Callable[[], str] @@ -40,38 +39,80 @@ async def resolve_recipient(r: RecipientLike) -> str: return cast("str", result) +_DEFAULT: Any = object() + + @dataclass class TempoRailSpec: - """Canonical config for the Tempo MPP rail.""" + """Canonical config for the Tempo MPP rail. + + Setting ``testnet=True`` auto-flips ``network`` / ``chain_id`` / ``token`` to + their testnet (Moderato, chain 42431) values when those fields are left at + their defaults. Explicit overrides still win. + """ recipient: RecipientLike - network: str = "tempo-mainnet" - chain_id: int = 4217 - token: str = USDC.tempo.mainnet.address + network: str = _DEFAULT + chain_id: int = _DEFAULT + token: str = _DEFAULT symbol: str = "USDC.e" decimals: int = 6 testnet: bool = False recommend: Literal["tempo", "agentscore-pay", "both"] = "both" + def __post_init__(self) -> None: + if self.testnet: + if self.network is _DEFAULT: + self.network = "tempo-testnet" + if self.chain_id is _DEFAULT: + self.chain_id = 42431 + if self.token is _DEFAULT: + self.token = USDC.tempo.testnet.address + else: + if self.network is _DEFAULT: + self.network = "tempo-mainnet" + if self.chain_id is _DEFAULT: + self.chain_id = 4217 + if self.token is _DEFAULT: + self.token = USDC.tempo.mainnet.address + @dataclass class X402BaseRailSpec: - """Canonical config for the x402 EVM (Base) rail.""" + """Canonical config for the x402 EVM (Base) rail. + + Setting ``network`` to a known CAIP-2 (``eip155:8453`` mainnet, + ``eip155:84532`` sepolia) auto-flips ``chain_id`` / ``token`` to the right + values when those fields are left at their defaults. Explicit overrides + still win. + """ recipient: RecipientLike - network: str = "eip155:8453" # CAIP-2 canonical - chain_id: int = 8453 - token: str = USDC.base.mainnet.address + network: str = "eip155:8453" + chain_id: int = _DEFAULT + token: str = _DEFAULT symbol: str = "USDC" decimals: int = 6 mode: Literal["exact", "upto"] = "exact" + def __post_init__(self) -> None: + if self.network == "eip155:84532": + if self.chain_id is _DEFAULT: + self.chain_id = 84532 + if self.token is _DEFAULT: + self.token = USDC.base.sepolia.address + else: + if self.chain_id is _DEFAULT: + self.chain_id = 8453 + if self.token is _DEFAULT: + self.token = USDC.base.mainnet.address + @dataclass class SolanaMppRailSpec: """Canonical config for the Solana MPP rail. - `signer` is an optional fee-payer signer for server-side fee sponsorship — + `signer` is an optional fee-payer signer for server-side fee sponsorship ; typed as `Any` to avoid hard-importing `@solana/kit`-equivalent types here. """ @@ -89,7 +130,7 @@ class SolanaMppRailSpec: class StripeRailSpec: """Canonical config for the Stripe SPT rail. - `recipient` is intentionally absent — Stripe rails use `profile_id` as the + `recipient` is intentionally absent; Stripe rails use `profile_id` as the merchant-side network identifier the agent's SPT is scoped to; the transaction recipient is the merchant's Stripe account, not an on-chain address. @@ -109,7 +150,7 @@ class TempoSessionRailSpec: `escrow_contract` is the merchant-deployed on-chain escrow that holds channel deposits + pays out cumulative vouchers on settlement. `store` is a `ChannelStore` instance (in-memory default for dev; Postgres / - Redis-backed in production) — typed as `Any` to avoid hard-importing + Redis-backed in production); typed as `Any` to avoid hard-importing `mppx`'s store interface here. """ @@ -130,9 +171,3 @@ class TempoSessionRailSpec: "X402BaseRailSpec", "resolve_recipient", ] - - -# Reference the networks module to keep an explicit dependency edge — the -# CAIP-2 default values above are sourced from `networks.tempo.mainnet.caip2` -# and `networks.base.mainnet.caip2` semantics. Asserted via tests. -_ = networks diff --git a/agentscore_commerce/payment/signer.py b/agentscore_commerce/payment/signer.py index 9e6f171..d8ef366 100644 --- a/agentscore_commerce/payment/signer.py +++ b/agentscore_commerce/payment/signer.py @@ -18,7 +18,7 @@ source (top-level or under ``challenge``). Credentials that omit the source field and rely on the Solana TransferChecked-authority fallback (extracting the signer from the signed-tx payload via ``@solana/kit``) are recovered by -the Node sibling, not by this Python helper — Python has no ``@solana/kit`` +the Node sibling, not by this Python helper; Python has no ``@solana/kit`` equivalent. Production MPP clients emit the ``did:pkh`` source field, so this is a non-issue for spec-compliant traffic. """ @@ -132,10 +132,34 @@ def _extract_from_mpp_auth(authorization: str) -> PaymentSigner | None: return None +def extract_signer_for_precheck(headers: Mapping[str, str]) -> PaymentSigner | None: + """One-call signer extraction across both supported credential formats. + + Tries the x402 ``X-Payment`` / ``payment-signature`` header first (EIP-3009 + ``payload.authorization.from``), then falls back to the MPP ``Authorization: + Payment`` header DID. Returns the first one that resolves, or ``None``. + + Use this for wallet-cap prechecks and other "did the agent claim to sign as + X?" checks where you need the signer BEFORE invoking Checkout; Checkout's + own settle path runs verification separately and surfaces the verified + signer on ``SettleOutcome.signer_address``. + """ + lower = {k.lower(): v for k, v in headers.items()} + x402 = lower.get("payment-signature") or lower.get("x-payment") + if x402: + signer = extract_payment_signer(x402) + if signer is not None: + return signer + authorization = lower.get("authorization") + if authorization and authorization.lower().startswith("payment "): + return extract_payment_signer(authorization_header=authorization) + return None + + def read_x402_payment_header(headers: Mapping[str, str]) -> str | None: """Read the x402 payment header from a request headers mapping (case-insensitive). - Tries ``payment-signature`` first, then ``x-payment`` — both names appear in the wild + Tries ``payment-signature`` first, then ``x-payment``; both names appear in the wild as the binary-friendly transport name evolved. Takes a mapping rather than a framework Request so the same helper works across FastAPI / Flask / Django / aiohttp / Sanic / ASGI. """ @@ -147,6 +171,7 @@ def read_x402_payment_header(headers: Mapping[str, str]) -> str | None: "PaymentSigner", "SignerNetwork", "extract_payment_signer", + "extract_signer_for_precheck", "extract_x402_signer", "read_x402_payment_header", ] diff --git a/agentscore_commerce/payment/solana.py b/agentscore_commerce/payment/solana.py new file mode 100644 index 0000000..87e650d --- /dev/null +++ b/agentscore_commerce/payment/solana.py @@ -0,0 +1,64 @@ +"""Solana MPP fee-payer signer loader. + +Buyers paying via Solana MPP USDC don't typically carry SOL for transaction +fees, so merchants commonly co-sign the buyer's ``solana/charge`` tx as the +fee payer (~5000 lamports per tx; negligible vs the USDC value moved). + +``load_solana_fee_payer(private_key=...)`` accepts a Solana keypair in any of +the three forms agents commonly export it as: + +* **base58** (Phantom export format) — 64-byte secret+public, or 32-byte + secret-only +* **hex** — 128-char string (64 bytes hex: 32-byte secret + 32-byte public) + +Returns a ``KeyPairSigner`` from ``solders`` ready to pass to ``mppx``'s +``solana/charge`` rail. Returns ``None`` when ``private_key`` is empty / absent +(so consumers can use ``os.environ.get(...)`` directly without null-checks). + +Requires the ``solders`` peer dependency (transitively via ``pympp[solana]``). +""" + +from __future__ import annotations + +import re +from typing import Any + + +def load_solana_fee_payer(private_key: str | None) -> Any | None: + """Load a Solana fee-payer signer from a keypair string. + + Accepts: + * 128-char hex (64 bytes: 32-byte secret + 32-byte public; pretrunc to 32) + * base58 (Phantom export: 64 bytes secret+public OR 32 bytes secret-only) + + Returns ``None`` when ``private_key`` is empty/None. + """ + if not private_key: + return None + + try: + from solders.keypair import Keypair # type: ignore[import-not-found] + except ImportError as err: + msg = "solders not installed — run `pip install 'pympp[solana]>=0.6'` for load_solana_fee_payer." + raise ImportError(msg) from err + + if re.fullmatch(r"[0-9a-fA-F]{128}", private_key): + secret = bytes.fromhex(private_key)[:32] + return Keypair.from_seed(secret) + + try: + import base58 # type: ignore[import-not-found] + except ImportError as err: + msg = "base58 not installed — required for base58-encoded Solana fee-payer keys." + raise ImportError(msg) from err + + decoded = base58.b58decode(private_key) + if len(decoded) == 64: + return Keypair.from_bytes(decoded) + if len(decoded) == 32: + return Keypair.from_seed(decoded) + msg = f"load_solana_fee_payer: base58 keypair must decode to 32 or 64 bytes, got {len(decoded)}" + raise ValueError(msg) + + +__all__ = ["load_solana_fee_payer"] diff --git a/agentscore_commerce/payment/x402_server.py b/agentscore_commerce/payment/x402_server.py index e98b7fa..3bb52e7 100644 --- a/agentscore_commerce/payment/x402_server.py +++ b/agentscore_commerce/payment/x402_server.py @@ -159,6 +159,14 @@ async def create_x402_server( msg = "x402 not installed — run `pip install 'x402[evm,fastapi]>=2.9,<3'` to use create_x402_server." raise ImportError(msg) + # Auto-select the Coinbase CDP facilitator when both env vars are present. + # Lets merchants drop the `facilitator: 'coinbase' if env else 'http'` ternary. + # Explicit `facilitator=` arg still wins. + import os + + if facilitator == "http" and os.environ.get("CDP_API_KEY_ID") and os.environ.get("CDP_API_KEY_SECRET"): + facilitator = "coinbase" + facilitator_instance: Any if facilitator == "coinbase": # Coinbase's x402 facilitator at api.cdp.coinbase.com requires a JWT diff --git a/agentscore_commerce/payment/x402_settle.py b/agentscore_commerce/payment/x402_settle.py index 8634fe5..747f81a 100644 --- a/agentscore_commerce/payment/x402_settle.py +++ b/agentscore_commerce/payment/x402_settle.py @@ -404,12 +404,10 @@ async def process_x402_settle( def settle_result_to_json_bytes(settle_result: Any) -> bytes: """Serialize the settle result to a base64-friendly JSON byte string. - x402 2.9's ``settle_payment`` returns a Pydantic ``SettleResponse`` model that - ``json.dumps`` rejects with ``TypeError: Object of type SettleResponse is not - JSON serializable``. Use ``model_dump_json(by_alias=True)`` for Pydantic models - (so emitted keys match the wire shape — ``errorReason`` / ``errorMessage`` rather - than the snake_case attrs) and fall through to ``json.dumps`` for plain dicts - (used by older x402 / test stubs). + Pydantic ``SettleResponse`` (x402's wire shape) goes through ``model_dump_json + (by_alias=True)`` so emitted keys match the wire shape (``errorReason`` / + ``errorMessage`` rather than the snake_case attrs). Plain dicts fall through + to ``json.dumps``. """ model_dump_json = getattr(settle_result, "model_dump_json", None) if callable(model_dump_json): diff --git a/pyproject.toml b/pyproject.toml index e6214e6..24512b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "1.8.1" +version = "2.0.0" description = "Agent commerce SDK for Python — identity middleware (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce." readme = "README.md" license = "MIT" diff --git a/tests/test_checkout.py b/tests/test_checkout.py index 453ddf8..2fc54fb 100644 --- a/tests/test_checkout.py +++ b/tests/test_checkout.py @@ -223,10 +223,12 @@ async def test_x402_settle_failure_returns_4xx_with_phase() -> None: x402_server=_StubX402Server(settle_success=False), ) result = await checkout.handle(_req(headers=_x402_headers_with_payload())) - assert result.status == 400 + # settle_failed phase classifies to 503 payment_provider_unavailable + # (transient on-chain settle outage; agent should retry or pick another rail). + assert result.status == 503 assert result.settled is False - assert result.settle_phase is not None - assert result.body["error"]["code"] == "payment_proof_invalid" + assert result.settle_phase == "settle_failed" + assert result.body["error"]["code"] == "payment_provider_unavailable" # ───────────────────────────────────────────────────────────────────────────── @@ -256,8 +258,10 @@ async def test_compose_mppx_returns_200_runs_on_settled() -> None: @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.""" +async def test_compose_mppx_returns_402_on_settle_leg_rejects_credential() -> None: + """When the agent sends Authorization: Payment and mppx returns 402 (credential + rejected), Checkout maps that to 400 payment_proof_invalid + the fresh + WWW-Authenticate from mppx so the agent's retry signs against the new directive.""" compose_mppx = AsyncMock( return_value=MppxComposeOutcome( status=402, @@ -271,8 +275,31 @@ async def test_compose_mppx_returns_402_composes_rich_body() -> None: compose_mppx=compose_mppx, ) result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"})) - assert result.status == 402 + assert result.status == 400 assert result.headers["www-authenticate"] == 'Payment id="ord_x"' + assert result.body["error"]["code"] == "payment_proof_invalid" + assert result.settle_phase == "verify_failed" + + +@pytest.mark.asyncio +async def test_compose_mppx_on_discovery_leg_layers_challenge_in_402() -> None: + """On the discovery leg (no Authorization header), Checkout calls compose_mppx + proactively to mint a fresh WWW-Authenticate, then composes it into the 402.""" + compose_mppx = AsyncMock( + return_value=MppxComposeOutcome( + status=402, + headers={"www-authenticate": 'Payment id="ord_y"'}, + ), + ) + 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()) + assert result.status == 402 + assert result.headers["www-authenticate"] == 'Payment id="ord_y"' assert "accepted_methods" in result.body @@ -297,7 +324,7 @@ def price(ctx: CheckoutContext) -> PricingResult: ) # Anonymous anon = await checkout.handle(_req()) - assert anon.body["amount_usd"] == "10.0" + assert anon.body["amount_usd"] == "10.00" # KYC'd verified = await checkout.handle( CheckoutRequest( @@ -308,7 +335,7 @@ def price(ctx: CheckoutContext) -> PricingResult: assess={"identity_status": "verified"}, ), ) - assert verified.body["amount_usd"] == "8.0" + assert verified.body["amount_usd"] == "8.00" @pytest.mark.asyncio diff --git a/tests/test_lifted_helpers.py b/tests/test_lifted_helpers.py index f9078af..f789052 100644 --- a/tests/test_lifted_helpers.py +++ b/tests/test_lifted_helpers.py @@ -4,6 +4,7 @@ import asyncio import base64 +import contextlib import json import time from collections.abc import Awaitable @@ -83,6 +84,104 @@ async def test_pi_cache_no_redis_url_falls_back_to_memory_only(): cache.stop() +# Fake redis.asyncio module so the Redis-backed branches of pi_cache run +# without requiring the optional `redis` peer dep in the test env. +class _FakeRedisAsync: + def __init__(self) -> None: + self.store: dict[str, str] = {} + self.get_raises: bool = False + + async def set(self, key: str, value: str, *, ex: int) -> None: + self.store[key] = value + + async def get(self, key: str) -> str | None: + if self.get_raises: + raise RuntimeError("simulated redis transient") + return self.store.get(key) + + +class _FakeRedisAsyncioModule: + def __init__(self) -> None: + self.instance = _FakeRedisAsync() + + def from_url(self, _url: str) -> _FakeRedisAsync: + return self.instance + + +@pytest.fixture +def _fake_redis(monkeypatch: pytest.MonkeyPatch) -> _FakeRedisAsync: + import sys + + module = _FakeRedisAsyncioModule() + # Inject under `redis.asyncio` so `import_module("redis.asyncio")` returns our stub. + monkeypatch.setitem(sys.modules, "redis.asyncio", module) + return module.instance + + +@pytest.mark.asyncio +async def test_pi_cache_redis_backed_cache_address_round_trip(_fake_redis: _FakeRedisAsync) -> None: + cache = create_pi_cache(redis_url="redis://localhost:6379", ttl_seconds=10) + await cache.cache_address("0xredis-test") + # set hits the Redis stub + assert any("0xredis-test" in k for k in _fake_redis.store) + # has_address hits the Redis stub and returns True + assert await cache.has_address("0xredis-test") is True + cache.stop() + + +@pytest.mark.asyncio +async def test_pi_cache_redis_get_error_falls_back_to_memory(_fake_redis: _FakeRedisAsync) -> None: + cache = create_pi_cache(redis_url="redis://localhost:6379", ttl_seconds=10) + await cache.cache_address("0xfallback") # writes to memory mirror + redis stub + _fake_redis.get_raises = True + # The redis-get raises, so the function falls back to the in-memory mirror. + assert await cache.has_address("0xfallback") is True + cache.stop() + + +@pytest.mark.asyncio +async def test_pi_cache_redis_url_set_but_module_missing_falls_back(monkeypatch: pytest.MonkeyPatch) -> None: + import sys + + # Force `import redis.asyncio` to raise even if a real redis lib is installed. + monkeypatch.setitem(sys.modules, "redis.asyncio", None) + cache = create_pi_cache(redis_url="redis://localhost:6379", ttl_seconds=10) + await cache.cache_address("0xnoredis-pkg") + assert await cache.has_address("0xnoredis-pkg") is True + cache.stop() + + +@pytest.mark.asyncio +async def test_pi_cache_eviction_loop_clears_expired_entries() -> None: + """Drive _evict_loop manually by zeroing the sleep delay so the eviction body runs.""" + import agentscore_commerce.stripe_multichain.pi_cache as pi_cache_mod + + real_sleep = asyncio.sleep + sleep_calls: list[float] = [] + + async def _no_sleep(delay: float) -> None: + sleep_calls.append(delay) + if len(sleep_calls) >= 2: + raise asyncio.CancelledError + # Let the loop yield once so other tasks (e.g. test polling) can interleave. + await real_sleep(0) + + pi_cache_mod.asyncio.sleep = _no_sleep # type: ignore[assignment] + try: + cache = create_pi_cache(ttl_seconds=0) + cache.cache_payment_intent("0xexp", "pi_exp") + cache.cache_network_addresses("pi_exp", {"base": "0xb"}) + await cache.cache_address("0xexp-addr") + # Wait for the eviction loop to fire (raises CancelledError after 2 iterations). + with contextlib.suppress(asyncio.CancelledError): + await real_sleep(0.05) + # After one tick, expired entries should be evicted. + assert cache.get_payment_intent_id("0xexp") is None + cache.stop() + finally: + pi_cache_mod.asyncio.sleep = real_sleep # type: ignore[assignment] + + # ───────────────────────────────────────────────────────────────────────────── # simulate_deposit_if_test_mode # ───────────────────────────────────────────────────────────────────────────── @@ -605,8 +704,8 @@ async def settle_payment(self, _payload: object, _req: object) -> SettleResponse @pytest.mark.asyncio -async def test_process_x402_settle_serializes_dict_settle_result_for_legacy_stubs(): - """Plain-dict settle results (from older x402 / test stubs) still serialize.""" +async def test_process_x402_settle_serializes_dict_settle_result(): + """Plain-dict settle results (test stubs) still serialize.""" class _DictSettleServer: def build_payment_requirements(self, _cfg: object, _ext: object = None) -> list: diff --git a/tests/test_payment_servers.py b/tests/test_payment_servers.py index 23c350e..3188bea 100644 --- a/tests/test_payment_servers.py +++ b/tests/test_payment_servers.py @@ -10,6 +10,7 @@ from __future__ import annotations import importlib.util +from typing import Any import pytest @@ -196,3 +197,167 @@ async def test_create_mppx_server_unknown_rail_spec_raises() -> None: secret_key="X" * 32, rails={"weird": "not-a-spec"}, # type: ignore[dict-item] ) + + +@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed") +@pytest.mark.asyncio +async def test_create_x402_server_auto_promotes_to_coinbase_when_env_set(monkeypatch: pytest.MonkeyPatch) -> None: + """When facilitator='http' but CDP env vars are present, auto-promote to Coinbase.""" + cdp_installed = importlib.util.find_spec("cdp.auth.utils.jwt") is not None + if not cdp_installed: + pytest.skip("cdp-sdk not installed") + monkeypatch.setenv("CDP_API_KEY_ID", "test-key-id") + monkeypatch.setenv("CDP_API_KEY_SECRET", "test-key-secret") + + server = await create_x402_server(facilitator="http", initialize=False) + facilitator = server._facilitator_clients[0] + assert facilitator.url == "https://api.cdp.coinbase.com/platform/v2/x402" + + +@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed") +@pytest.mark.asyncio +async def test_create_x402_server_passthrough_prebuilt_facilitator() -> None: + """A non-string facilitator argument is passed through verbatim.""" + sentinel = object() + server = await create_x402_server(facilitator=sentinel, initialize=False) + assert server._facilitator_clients[0] is sentinel + + +@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed") +@pytest.mark.asyncio +async def test_create_x402_server_upto_rail_registers_upto_scheme() -> None: + server = await create_x402_server( + facilitator="http", + rails=["x402-base-sepolia-upto"], + initialize=False, + ) + assert "eip155:84532" in server._schemes + # upto schemes register under their canonical scheme name in pympp 0.6+. + sepolia_schemes = server._schemes["eip155:84532"] + assert len(sepolia_schemes) >= 1 + + +@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed") +@pytest.mark.asyncio +async def test_create_x402_server_custom_scheme_registered() -> None: + """A CustomScheme entry is registered alongside symbolic rails.""" + from x402.mechanisms.evm.exact.server import ExactEvmScheme + + from agentscore_commerce.payment import CustomScheme + + custom = CustomScheme(network="eip155:8453", scheme=ExactEvmScheme()) + server = await create_x402_server( + facilitator="http", + schemes=[custom], + initialize=False, + ) + assert "eip155:8453" in server._schemes + + +@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed") +@pytest.mark.asyncio +async def test_create_x402_server_bazaar_registers_extension(monkeypatch: pytest.MonkeyPatch) -> None: + """`bazaar=True` looks up the extension and calls server.register_extension.""" + # `find_spec` triggers a real import via x402's __init__, so guard against + # transitive ImportError (jsonschema / idna are part of `x402[extensions]`). + try: + import x402.extensions.bazaar + except ImportError: + pytest.skip("x402[extensions] not installed (jsonschema/idna unavailable)") + + registered: list[Any] = [] + + # Patch register_extension on the class BEFORE creating the server so the + # bazaar branch records its extension call. + import x402 + + orig_init = x402.x402ResourceServer.__init__ + + def patched_init(self: Any, **kw: Any) -> None: + orig_init(self, **kw) + self.register_extension = registered.append # type: ignore[attr-defined] + + monkeypatch.setattr(x402.x402ResourceServer, "__init__", patched_init) + await create_x402_server( + facilitator=object(), # opaque facilitator; never called with initialize=False + bazaar=True, + initialize=False, + ) + assert len(registered) == 1 + + +@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed") +def test_build_x402_accepts_for_402_accepts_dict_requirements() -> None: + """`build_payment_requirements` may return plain dicts (older versions / stubs).""" + from agentscore_commerce.payment import build_x402_accepts_for_402 + + class _DictServer: + def build_payment_requirements(self, _config: Any, _ext: Any = None) -> list[dict[str, Any]]: + return [{"scheme": "exact", "network": "eip155:8453", "payTo": "0xDEAD"}] + + accepts = build_x402_accepts_for_402( + _DictServer(), + network="eip155:8453", + price="$0.10", + pay_to="0x000000000000000000000000000000000000dEaD", + ) + assert accepts == [{"scheme": "exact", "network": "eip155:8453", "payTo": "0xDEAD"}] + + +@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed") +def test_build_x402_accepts_for_402_rejects_unknown_requirement_type() -> None: + """Anything other than a Pydantic model or dict raises TypeError.""" + from agentscore_commerce.payment import build_x402_accepts_for_402 + + class _BadServer: + def build_payment_requirements(self, _config: Any, _ext: Any = None) -> list[object]: + return [object()] + + with pytest.raises(TypeError, match="expected a Pydantic PaymentRequirements or a dict"): + build_x402_accepts_for_402( + _BadServer(), + network="eip155:8453", + price="$0.10", + pay_to="0x000000000000000000000000000000000000dEaD", + ) + + +@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed") +@pytest.mark.asyncio +async def test_coinbase_facilitator_emits_per_endpoint_bearer(monkeypatch: pytest.MonkeyPatch) -> None: + """The Coinbase auth provider mints a per-endpoint JWT via cdp-sdk's generate_jwt. + + Exercises `_mint_bearer` + `_create_headers` so they show up in coverage. + Mocks the real cdp JWT signer so the test doesn't need a valid EC private key. + """ + cdp_installed = importlib.util.find_spec("cdp.auth.utils.jwt") is not None + if not cdp_installed: + pytest.skip("cdp-sdk not installed") + monkeypatch.setenv("CDP_API_KEY_ID", "test-key-id") + monkeypatch.setenv("CDP_API_KEY_SECRET", "test-secret") + import cdp.auth.utils.jwt as cdp_jwt + + captured: list[tuple[str, str]] = [] + + def _fake_generate_jwt(options: Any) -> str: + captured.append((options.request_method, options.request_path)) + return "fake-jwt-token" + + monkeypatch.setattr(cdp_jwt, "generate_jwt", _fake_generate_jwt) + + server = await create_x402_server( + facilitator="coinbase", + rails=["x402-base-mainnet"], + initialize=False, + ) + facilitator = server._facilitator_clients[0] + auth_provider = facilitator._auth_provider + headers = auth_provider.get_auth_headers() + assert headers.verify["Authorization"] == "Bearer fake-jwt-token" + assert headers.settle["Authorization"] == "Bearer fake-jwt-token" + assert headers.supported["Authorization"] == "Bearer fake-jwt-token" + # All three endpoints were minted distinct JWTs. + assert len(captured) == 3 + methods = [c[0] for c in captured] + assert "POST" in methods # verify + settle + assert "GET" in methods # supported diff --git a/tests/test_seamless_helpers.py b/tests/test_seamless_helpers.py new file mode 100644 index 0000000..82fc091 --- /dev/null +++ b/tests/test_seamless_helpers.py @@ -0,0 +1,1463 @@ +"""Coverage for the seamless-merchant helpers shipped in the latest SDK additions: + +* ``lazy_x402_server`` / ``lazy_mppx_server``; memoized async getters +* ``extract_signer_for_precheck``; one-call signer across x402 + mpp headers +* ``make_mppx_compose_hook``; canonical ``compose_mppx`` factory +* ``purchase_mode_note`` / ``build_agentscore_onboarding_steps`` / + ``standard_endpoint_descriptions`` / ``build_success_next_steps`` +* ``build_redemption_skill_md`` +* The new validation_response_* framework variants + ``validation_envelope`` +* Checkout framework adapters (handle_flask / handle_django / handle_aiohttp / + handle_sanic); handle_fastapi is already exercised in test_checkout.py. +""" + +from __future__ import annotations + +import asyncio +import base64 +import contextlib as _contextlib +import json +import os +from typing import Any +from unittest.mock import patch + +import pytest + +from agentscore_commerce import ( + Checkout, + CheckoutRequest, + MppxComposeOutcome, + SettleOutcome, + validation_envelope, + validation_response_aiohttp, + validation_response_django, + validation_response_fastapi, + validation_response_flask, + validation_response_sanic, +) +from agentscore_commerce.checkout_hooks import make_mppx_compose_hook +from agentscore_commerce.discovery import ( + PURCHASE_MODE_NOTES, + build_agentscore_onboarding_steps, + build_redemption_skill_md, + build_success_next_steps, + purchase_mode_note, + standard_endpoint_descriptions, +) +from agentscore_commerce.payment import ( + PaymentSigner, + TempoRailSpec, + X402BaseRailSpec, + extract_signer_for_precheck, + lazy_mppx_server, + lazy_x402_server, +) + + +def _req(headers: dict[str, str] | None = None) -> CheckoutRequest: + return CheckoutRequest( + method="POST", + url="https://api.example/purchase", + headers=headers or {}, + body={"item": "wine"}, + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# lazy_x402_server / lazy_mppx_server +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_lazy_x402_server_memoizes_single_instance() -> None: + spec = X402BaseRailSpec(recipient="0x" + "00" * 19 + "dE" + "aD", network="eip155:84532") + sentinel = object() + calls = 0 + + async def _fake_create(*, facilitator: str, rails: list[str]) -> object: + nonlocal calls + calls += 1 + assert facilitator == "http" + assert rails == ["x402-base-sepolia"] + return sentinel + + with patch("agentscore_commerce.payment.lazy.create_x402_server", _fake_create): + getter = lazy_x402_server(spec=spec) + a, b = await asyncio.gather(getter(), getter()) + assert a is sentinel + assert b is sentinel + assert calls == 1 + + +@pytest.mark.asyncio +async def test_lazy_x402_server_picks_coinbase_with_full_creds() -> None: + spec = X402BaseRailSpec(recipient="0x" + "00" * 19 + "dE" + "aD") + + async def _fake_create(*, facilitator: str, rails: list[str]) -> str: + return f"{facilitator}:{rails[0]}" + + with patch("agentscore_commerce.payment.lazy.create_x402_server", _fake_create): + getter = lazy_x402_server( + spec=spec, + cdp_api_key_id="k", + cdp_api_key_secret="s", + ) + out = await getter() + assert out == "coinbase:x402-base-mainnet" + + +def test_lazy_x402_server_rejects_unknown_network() -> None: + bad = X402BaseRailSpec(recipient="0x" + "00" * 19 + "dE" + "aD") + object.__setattr__(bad, "network", "eip155:1") + with pytest.raises(ValueError, match=r"unsupported X402BaseRailSpec\.network"): + lazy_x402_server(spec=bad) + + +@pytest.mark.asyncio +async def test_lazy_mppx_server_memoizes_single_instance() -> None: + spec = TempoRailSpec(recipient="0x" + "00" * 19 + "dE" + "aD") + sentinel = object() + calls = 0 + + async def _fake_create(*, secret_key: str, rails: Any, realm: str | None) -> object: + nonlocal calls + calls += 1 + assert secret_key == "secret" + assert "tempo" in rails + assert realm == "test-realm" + return sentinel + + with patch("agentscore_commerce.payment.lazy.create_mppx_server", _fake_create): + getter = lazy_mppx_server( + rails={"tempo": spec}, + secret_key="secret", + realm="test-realm", + ) + a, b = await asyncio.gather(getter(), getter()) + assert a is sentinel + assert b is sentinel + assert calls == 1 + + +# ───────────────────────────────────────────────────────────────────────────── +# extract_signer_for_precheck +# ───────────────────────────────────────────────────────────────────────────── + + +def _encode_x402_header(payload: dict[str, Any]) -> str: + return base64.b64encode(json.dumps(payload).encode()).decode() + + +def test_extract_signer_for_precheck_reads_x402_payment_signature() -> None: + payload = { + "x402Version": 2, + "scheme": "exact", + "network": "eip155:84532", + "payload": { + "authorization": { + "from": "0xAbC0000000000000000000000000000000000001", + }, + }, + } + headers = {"Payment-Signature": _encode_x402_header(payload)} + signer = extract_signer_for_precheck(headers) + assert signer == PaymentSigner( + address="0xabc0000000000000000000000000000000000001", + network="evm", + ) + + +def test_extract_signer_for_precheck_reads_x_payment_alias() -> None: + payload = { + "x402Version": 2, + "scheme": "exact", + "network": "eip155:84532", + "payload": {"authorization": {"from": "0xAbC0000000000000000000000000000000000002"}}, + } + signer = extract_signer_for_precheck({"X-Payment": _encode_x402_header(payload)}) + assert signer is not None + assert signer.address.endswith("002") + + +def test_extract_signer_for_precheck_no_headers_returns_none() -> None: + assert extract_signer_for_precheck({}) is None + assert extract_signer_for_precheck({"authorization": "Bearer not-a-payment"}) is None + + +def test_extract_signer_for_precheck_garbled_x402_falls_through() -> None: + # Garbled x402 returns None, then we fall through to authorization (which is missing → None). + assert extract_signer_for_precheck({"Payment-Signature": "!!!notbase64!!!"}) is None + + +# ───────────────────────────────────────────────────────────────────────────── +# make_mppx_compose_hook +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_make_mppx_compose_hook_returns_402_when_no_pricing() -> None: + async def _getter() -> Any: + raise AssertionError("server should not be touched when pricing is None") + + hook = make_mppx_compose_hook(server_getter=_getter) + + class _Ctx: + request = _req() + pricing = None + + out = await hook(_Ctx()) + assert out.status == 402 + + +@pytest.mark.asyncio +async def test_make_mppx_compose_hook_emits_challenge_headers_on_402() -> None: + class _Challenge: + def to_www_authenticate(self, realm: str) -> str: + return f'Payment realm="{realm}"' + + class _Mpp: + realm = "test-realm" + + async def charge(self, *, authorization: str | None, amount: str) -> _Challenge: + assert authorization is None + assert amount == "1.00" + return _Challenge() + + async def _getter() -> _Mpp: + return _Mpp() + + hook = make_mppx_compose_hook(server_getter=_getter) + + class _Pricing: + amount_usd = 1.0 + + class _Ctx: + request = _req() + pricing = _Pricing() + + out = await hook(_Ctx()) + assert out.status == 402 + assert out.headers == {"www-authenticate": 'Payment realm="test-realm"'} + + +@pytest.mark.asyncio +async def test_make_mppx_compose_hook_lifts_signer_from_did_pkh_eip155() -> None: + class _Credential: + source = "did:pkh:eip155:8453:0xABCD000000000000000000000000000000000003" + + class _Receipt: + reference = "0xtx_hash" + transaction = None + + class _Mpp: + realm = "r" + + async def charge(self, *, authorization: str | None, amount: str) -> tuple: + return (_Credential(), _Receipt()) + + async def _getter() -> _Mpp: + return _Mpp() + + hook = make_mppx_compose_hook(server_getter=_getter) + + class _Pricing: + amount_usd = 0.1 + + class _Ctx: + request = _req({"authorization": "Payment somevalidcred"}) + pricing = _Pricing() + + out = await hook(_Ctx()) + assert out.status == 200 + assert out.tx_hash == "0xtx_hash" + assert out.signer_address == "0xabcd000000000000000000000000000000000003" + assert out.signer_network == "evm" + + +@pytest.mark.asyncio +async def test_make_mppx_compose_hook_returns_402_when_charge_raises() -> None: + class _Mpp: + realm = "r" + + async def charge(self, *, authorization: str | None, amount: str) -> Any: + raise RuntimeError("pympp blew up") + + async def _getter() -> _Mpp: + return _Mpp() + + hook = make_mppx_compose_hook(server_getter=_getter) + + class _Pricing: + amount_usd = 1.0 + + class _Ctx: + request = _req() + pricing = _Pricing() + + out = await hook(_Ctx()) + assert out.status == 402 + + +# ───────────────────────────────────────────────────────────────────────────── +# discovery/agentscore_content + redemption_md +# ───────────────────────────────────────────────────────────────────────────── + + +def test_purchase_mode_note_returns_known_modes() -> None: + for mode in ("redemption_only", "coupon_applicable", "paid_only"): + assert purchase_mode_note(mode) == PURCHASE_MODE_NOTES[mode] + + +def test_purchase_mode_note_unknown_returns_empty_string() -> None: + assert purchase_mode_note("not-a-real-mode") == "" + + +def test_build_agentscore_onboarding_steps_substitutes_merchant_url_and_rails() -> None: + steps = build_agentscore_onboarding_steps( + merchant_name="AgentScore Store", + app_url="https://store.example", + accepted_rails=["tempo", "x402-base", "solana-mpp"], + requires_kyc=True, + ) + text = "\n".join(steps) + assert "AgentScore Store" in text + assert "Tempo USDC" in text + assert "x402 USDC on Base" in text + assert "Solana SPL USDC" in text + assert "tempo | base | solana" in text + assert "required for this merchant" in text + assert "https://store.example/catalog" in text + assert "https://store.example/purchase" in text + + +def test_build_agentscore_onboarding_steps_no_kyc_drops_required_clause() -> None: + steps = build_agentscore_onboarding_steps( + merchant_name="API Co", + app_url="https://api.example", + accepted_rails=["x402-base"], + requires_kyc=False, + ) + assert "required for this merchant" not in "\n".join(steps) + + +def test_build_agentscore_onboarding_steps_unknown_rails_passed_through() -> None: + steps = build_agentscore_onboarding_steps( + merchant_name="X", + app_url="https://x.example", + accepted_rails=["future-rail"], + ) + assert "future-rail" in "\n".join(steps) + assert "tempo|base" in steps[-1] # default fallback when no mappable rail present + + +def test_standard_endpoint_descriptions_mentions_all_routes() -> None: + desc = standard_endpoint_descriptions() + assert "GET /catalog" in desc + assert "POST /purchase" in desc + assert "GET /orders/{id}" in desc + assert "GET /orders/{id}/status" not in desc + + with_status = standard_endpoint_descriptions(include_order_status_route=True) + assert "GET /orders/{id}/status" in with_status + + +def test_build_success_next_steps_omits_eta_when_missing() -> None: + out = build_success_next_steps(order_status_url="https://x/orders/1") + assert out == { + "action": "done", + "order_status_url": "https://x/orders/1", + "user_message": ( + "Order complete. Your AgentScore Passport is now active across every AgentScore-gated merchant." + ), + } + + +def test_build_success_next_steps_includes_eta_when_provided() -> None: + out = build_success_next_steps( + order_status_url="https://x/orders/1", + fulfillment_eta="ships in 3-5 business days", + ) + assert out["fulfillment_eta"] == "ships in 3-5 business days" + + +def test_build_redemption_skill_md_substitutes_merchant_and_url() -> None: + md = build_redemption_skill_md( + merchant_name="AgentScore Store", + app_url="https://store.example", + ) + assert "AgentScore Store" in md + assert "https://store.example/catalog" in md + assert "https://store.example/purchase" in md + assert "Don't have a code?" not in md + + +def test_build_redemption_skill_md_with_peer_pointer_emits_section() -> None: + md = build_redemption_skill_md( + merchant_name="AgentScore Store", + app_url="https://store.example", + peer_merchant_pointer="https://martin.example", + sku_intro="a custom SKU intro.", + ) + assert "Don't have a code?" in md + # `see: ` prefix anchors the substring inside the rendered markdown section + # rather than appearing as a bare URL match (CodeQL py/incomplete-url-substring-sanitization). + assert "see: https://martin.example\n" in md + assert "a custom SKU intro." in md + + +# ───────────────────────────────────────────────────────────────────────────── +# validation_envelope + per-framework validation_response_* +# ───────────────────────────────────────────────────────────────────────────── + + +def test_validation_envelope_shape() -> None: + out = validation_envelope(code="bad", message="nope", extra={"hint": "x"}) + assert out["error"]["code"] == "bad" + assert out["error"]["message"] == "nope" + assert out["next_steps"]["action"] == "fix_request" + assert out["next_steps"]["user_message"] == "nope" + assert out.get("hint") == "x" + + +def test_validation_response_fastapi_returns_jsonresponse_with_status() -> None: + from fastapi.responses import JSONResponse + + resp = validation_response_fastapi(code="bad", message="nope", status=422) + assert isinstance(resp, JSONResponse) + assert resp.status_code == 422 + assert json.loads(resp.body)["error"]["code"] == "bad" + + +def test_validation_response_flask_returns_response_with_status() -> None: + flask = pytest.importorskip("flask") + + app = flask.Flask(__name__) + with app.app_context(): + resp = validation_response_flask(code="bad", message="nope", status=400) + assert resp.status_code == 400 + body = json.loads(resp.get_data(as_text=True)) + assert body["error"]["code"] == "bad" + + +def test_validation_response_django_returns_jsonresponse_with_status() -> None: + pytest.importorskip("django") + from django.conf import settings as dj_settings + + if not dj_settings.configured: + dj_settings.configure(DEBUG=True, ALLOWED_HOSTS=["*"]) + + resp = validation_response_django(code="bad", message="nope", status=400) + assert resp.status_code == 400 + body = json.loads(resp.content) + assert body["error"]["code"] == "bad" + + +def test_validation_response_aiohttp_returns_web_response() -> None: + pytest.importorskip("aiohttp") + resp = validation_response_aiohttp(code="bad", message="nope", status=400) + assert resp.status == 400 + assert b'"bad"' in resp.body + + +def test_validation_response_sanic_returns_http_response() -> None: + pytest.importorskip("sanic") + resp = validation_response_sanic(code="bad", message="nope", status=400) + assert resp.status == 400 + body_bytes = resp.body if isinstance(resp.body, bytes) else resp.body.encode() + assert b'"bad"' in body_bytes + + +# ───────────────────────────────────────────────────────────────────────────── +# Checkout framework adapters (handle_flask, handle_django, handle_aiohttp, handle_sanic) +# ───────────────────────────────────────────────────────────────────────────── + + +def _minimal_checkout() -> Checkout: + """Build a Checkout that returns inline on settle so each adapter can exercise + handle_ end-to-end without needing a real x402 server.""" + from agentscore_commerce.payment.rail_spec import TempoRailSpec + + async def _pricing(ctx: Any) -> Any: + from agentscore_commerce.checkout import PricingResult + + return PricingResult(amount_usd=1.0) + + async def _compose_mppx(ctx: Any) -> MppxComposeOutcome: + # Discovery leg: no auth → 402 with WWW-Auth. + if not ctx.request.headers.get("authorization"): + return MppxComposeOutcome(status=402, headers={"www-authenticate": 'Payment realm="test"'}) + return MppxComposeOutcome( + status=200, + rail_key="tempo", + tx_hash="0xtest", + signer_address="0x" + "00" * 19 + "dE", + signer_network="evm", + ) + + async def _on_settled(ctx: Any, outcome: SettleOutcome) -> dict[str, Any]: + return {"order_id": "o-1", "tx_hash": outcome.tx_hash} + + return Checkout( + rails={"tempo": TempoRailSpec(recipient="0x" + "00" * 19 + "dE" + "aD")}, + url="https://api.example/purchase", + compute_pricing=_pricing, + compose_mppx=_compose_mppx, + on_settled=_on_settled, + ) + + +@pytest.mark.asyncio +async def test_handle_aiohttp_returns_402_on_discovery_leg() -> None: + aiohttp_pytest = pytest.importorskip("aiohttp") + from aiohttp import web + from aiohttp.test_utils import make_mocked_request + + checkout = _minimal_checkout() + req = make_mocked_request("POST", "/purchase", headers={}) + resp = await checkout.handle_aiohttp(req, body={"item": "wine"}) + assert isinstance(resp, web.Response) + assert resp.status == 402 + _ = aiohttp_pytest # marker for ruff + + +@pytest.mark.asyncio +async def test_handle_aiohttp_returns_invalid_body_envelope_when_body_missing() -> None: + pytest.importorskip("aiohttp") + from aiohttp.test_utils import make_mocked_request + + checkout = _minimal_checkout() + + class _NoJsonReq: + method = "POST" + url = "/purchase" + headers: dict[str, str] = {} + + async def json(self) -> Any: + raise ValueError("not json") + + resp = await checkout.handle_aiohttp(_NoJsonReq()) + assert resp.status == 400 + _ = make_mocked_request # ruff + + +@pytest.mark.asyncio +async def test_handle_sanic_returns_402_on_discovery_leg() -> None: + pytest.importorskip("sanic") + + class _SanicReq: + method = "POST" + url = "/purchase" + headers: dict[str, str] = {} + + @property + def json(self) -> dict[str, Any]: + return {"item": "wine"} + + checkout = _minimal_checkout() + resp = await checkout.handle_sanic(_SanicReq()) + assert resp.status == 402 + + +def test_handle_flask_returns_402_on_discovery_leg() -> None: + flask = pytest.importorskip("flask") + + app = flask.Flask(__name__) + checkout = _minimal_checkout() + + with app.test_request_context("/purchase", method="POST", json={"item": "wine"}): + from flask import request as flask_request + + resp = checkout.handle_flask(flask_request) + assert resp.status_code == 402 + + +# ───────────────────────────────────────────────────────────────────────────── +# Checkout gate hooks (CheckoutGateConfig) +# ───────────────────────────────────────────────────────────────────────────── + + +def _checkout_with_gate(gate: Any) -> Checkout: + """Build a Checkout configured with `gate=...` whose settle path returns inline.""" + from agentscore_commerce.checkout import Checkout, PricingResult + from agentscore_commerce.payment.rail_spec import TempoRailSpec + + async def _pricing(_ctx: Any) -> PricingResult: + return PricingResult(amount_usd=1.0) + + async def _compose_mppx(ctx: Any) -> MppxComposeOutcome: + if not ctx.request.headers.get("authorization"): + return MppxComposeOutcome(status=402, headers={"www-authenticate": 'Payment realm="test"'}) + return MppxComposeOutcome( + status=200, + rail_key="tempo", + tx_hash="0xtest", + signer_address="0x" + "00" * 19 + "dE", + signer_network="evm", + ) + + async def _on_settled(_ctx: Any, outcome: SettleOutcome) -> dict[str, Any]: + return {"order_id": "o-1", "tx_hash": outcome.tx_hash} + + return Checkout( + rails={"tempo": TempoRailSpec(recipient="0x" + "00" * 19 + "dE" + "aD")}, + url="https://api.example/purchase", + compute_pricing=_pricing, + compose_mppx=_compose_mppx, + on_settled=_on_settled, + gate=gate, + ) + + +@pytest.mark.asyncio +async def test_gate_run_gate_escape_hatch_allow_pass_through() -> None: + """`gate.run_gate` returning None means allow → request continues to settle.""" + from agentscore_commerce.checkout import CheckoutGateConfig + + seen: list[Any] = [] + + async def _run_gate(ctx: Any) -> None: + seen.append(ctx) + return + + gate = CheckoutGateConfig(api_key="k", run_gate=_run_gate) + checkout = _checkout_with_gate(gate) + result = await checkout.handle( + CheckoutRequest( + method="POST", + url="https://api.example/purchase", + headers={"authorization": "Payment "}, + body={"item": "wine"}, + ), + ) + assert result.status == 200 + assert seen # run_gate was actually invoked + + +@pytest.mark.asyncio +async def test_gate_run_gate_escape_hatch_deny_returns_canonical_envelope() -> None: + """`gate.run_gate` returning a dict → denial; status + body propagate.""" + from agentscore_commerce.checkout import CheckoutGateConfig + + async def _run_gate(_ctx: Any) -> dict[str, Any]: + return {"status": 403, "body": {"error": {"code": "custom_denied"}}, "headers": {"X-Custom": "v"}} + + gate = CheckoutGateConfig(api_key="k", run_gate=_run_gate) + checkout = _checkout_with_gate(gate) + result = await checkout.handle( + CheckoutRequest( + method="POST", + url="https://api.example/purchase", + headers={"authorization": "Payment "}, + body={}, + ), + ) + assert result.status == 403 + assert result.body["error"]["code"] == "custom_denied" + assert result.headers["X-Custom"] == "v" + assert result.settled is False + assert result.settle_phase == "gate_denied" + + +@pytest.mark.asyncio +async def test_gate_run_gate_returning_unexpected_type_raises() -> None: + """`gate.run_gate` returning something other than None/dict raises TypeError.""" + from agentscore_commerce.checkout import CheckoutGateConfig + + async def _run_gate(_ctx: Any) -> str: + return "not-allowed-shape" + + gate = CheckoutGateConfig(api_key="k", run_gate=_run_gate) + checkout = _checkout_with_gate(gate) + with pytest.raises(TypeError, match="must return None"): + await checkout.handle( + CheckoutRequest( + method="POST", + url="https://api.example/purchase", + headers={"authorization": "Payment "}, + body={}, + ), + ) + + +@pytest.mark.asyncio +async def test_gate_per_request_policy_none_skips_gate(monkeypatch: pytest.MonkeyPatch) -> None: + """`per_request_policy(ctx) → None` skips the gate entirely → settle proceeds.""" + from agentscore_commerce.checkout import CheckoutGateConfig + + async def _policy(_ctx: Any) -> None: + return None + + gate = CheckoutGateConfig(api_key="k", per_request_policy=_policy) + checkout = _checkout_with_gate(gate) + result = await checkout.handle( + CheckoutRequest( + method="POST", + url="https://api.example/purchase", + headers={"authorization": "Payment "}, + body={}, + ), + ) + assert result.status == 200 + + +@pytest.mark.asyncio +async def test_gate_on_denied_callback_reshapes_denial(monkeypatch: pytest.MonkeyPatch) -> None: + """`gate.on_denied` returning `{body, status}` overrides the canonical body.""" + from agentscore_commerce.checkout import CheckoutGateConfig + from agentscore_commerce.identity.policy import GateResult + + async def _mock_run_gate(_raw: Any, _gate_instance: Any, *, enforcement: Any = None) -> GateResult: + return GateResult( + status="denied", + denial_body={"error": {"code": "kyc_required"}}, + denial_status=403, + ) + + monkeypatch.setattr( + "agentscore_commerce.identity.policy.run_gate_with_enforcement", + _mock_run_gate, + ) + + async def _on_denied(_ctx: Any, body: dict[str, Any]) -> dict[str, Any]: + return {"status": 402, "body": {**body, "augmented": True}} + + gate = CheckoutGateConfig(api_key="k", require_kyc=True, on_denied=_on_denied) + checkout = _checkout_with_gate(gate) + result = await checkout.handle( + CheckoutRequest( + method="POST", + url="https://api.example/purchase", + headers={"authorization": "Payment "}, + body={}, + raw=object(), # gate path requires `raw` to be set + ), + ) + assert result.status == 402 + assert result.body["augmented"] is True + assert result.body["error"]["code"] == "kyc_required" + + +@pytest.mark.asyncio +async def test_gate_allow_attaches_capture_wallet(monkeypatch: pytest.MonkeyPatch) -> None: + """A successful gate allow stashes `ctx.capture_wallet` for `on_settled`.""" + from agentscore_commerce.checkout import CheckoutGateConfig + from agentscore_commerce.identity.policy import GateResult + + async def _mock_run_gate(_raw: Any, _gate_instance: Any, *, enforcement: Any = None) -> GateResult: + return GateResult(status="verified", denial_body=None, denial_status=None) + + monkeypatch.setattr( + "agentscore_commerce.identity.policy.run_gate_with_enforcement", + _mock_run_gate, + ) + + capture_calls: list[dict[str, Any]] = [] + + async def _on_settled(ctx: Any, outcome: SettleOutcome) -> dict[str, Any]: + if ctx.capture_wallet is not None: + # Don't actually fire — would call AgentScoreCore — but mark that the closure exists. + capture_calls.append({"available": True, "tx": outcome.tx_hash}) + return {"order_id": "o-1"} + + from agentscore_commerce.checkout import Checkout, PricingResult + from agentscore_commerce.payment.rail_spec import TempoRailSpec + + async def _pricing(_ctx: Any) -> PricingResult: + return PricingResult(amount_usd=1.0) + + async def _compose_mppx(ctx: Any) -> MppxComposeOutcome: + if not ctx.request.headers.get("authorization"): + return MppxComposeOutcome(status=402, headers={"www-authenticate": 'Payment realm="test"'}) + return MppxComposeOutcome( + status=200, + rail_key="tempo", + tx_hash="0xtest", + signer_address="0x" + "00" * 19 + "dE", + signer_network="evm", + ) + + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0x" + "00" * 19 + "dE" + "aD")}, + url="https://api.example/purchase", + compute_pricing=_pricing, + compose_mppx=_compose_mppx, + on_settled=_on_settled, + gate=CheckoutGateConfig(api_key="k", require_kyc=True), + ) + result = await checkout.handle( + CheckoutRequest( + method="POST", + url="https://api.example/purchase", + headers={"authorization": "Payment ", "x-operator-token": "opc_test_token"}, + body={}, + raw=object(), + ), + ) + assert result.status == 200 + assert capture_calls == [{"available": True, "tx": "0xtest"}] + + +@pytest.mark.asyncio +async def test_checkout_accepted_rails_dedupes_per_protocol() -> None: + """`Checkout.accepted_rails` folds tempo+tempo_session into one and emits per-protocol slugs.""" + from agentscore_commerce.checkout import Checkout, PricingResult + from agentscore_commerce.payment import SolanaMppRailSpec, StripeRailSpec, TempoSessionRailSpec + + async def _pricing(_ctx: Any) -> PricingResult: + return PricingResult(amount_usd=1.0) + + checkout = Checkout( + rails={ + "tempo": TempoRailSpec(recipient="0x" + "00" * 20), + "tempo_session": TempoSessionRailSpec( + recipient="0x" + "00" * 20, + escrow_contract="0x" + "11" * 20, + store=object(), + ), + "base": X402BaseRailSpec(recipient="0x" + "00" * 20), + "solana": SolanaMppRailSpec(recipient="SoLa"), + "stripe": StripeRailSpec(profile_id="profile_abc"), + }, + url="https://x/purchase", + compute_pricing=_pricing, + ) + rails = checkout.accepted_rails + # tempo + tempo_session fold to "tempo_mpp" + assert rails.count("tempo_mpp") == 1 + assert "x402_base" in rails + assert "solana_mpp" in rails + assert "stripe" in rails + + +@pytest.mark.asyncio +async def test_checkout_accepted_method_names_emits_protocol_methods() -> None: + from agentscore_commerce.checkout import Checkout, PricingResult + from agentscore_commerce.payment import SolanaMppRailSpec, StripeRailSpec + + async def _pricing(_ctx: Any) -> PricingResult: + return PricingResult(amount_usd=1.0) + + checkout = Checkout( + rails={ + "tempo": TempoRailSpec(recipient="0x" + "00" * 20), + "base": X402BaseRailSpec(recipient="0x" + "00" * 20), + "solana": SolanaMppRailSpec(recipient="SoLa"), + "stripe": StripeRailSpec(profile_id="profile_abc"), + }, + url="https://x/purchase", + compute_pricing=_pricing, + ) + names = checkout.accepted_method_names + assert "tempo/charge" in names + assert "x402/exact (base)" in names + assert "solana/charge" in names + assert "stripe/spt" in names + + +@pytest.mark.asyncio +async def test_capture_wallet_closure_calls_acapture_wallet(monkeypatch: pytest.MonkeyPatch) -> None: + """The closure installed on ctx.capture_wallet calls AgentScoreCore.acapture_wallet.""" + from agentscore_commerce.checkout import ( + Checkout, + CheckoutGateConfig, + PricingResult, + ) + from agentscore_commerce.identity.policy import GateResult + + async def _mock_run_gate(_raw: Any, _gate_instance: Any, *, enforcement: Any = None) -> GateResult: + return GateResult(status="verified", denial_body=None, denial_status=None) + + monkeypatch.setattr( + "agentscore_commerce.identity.policy.run_gate_with_enforcement", + _mock_run_gate, + ) + + capture_calls: list[dict[str, Any]] = [] + + async def _mock_acapture_wallet( + self: Any, *, operator_token: str, wallet_address: str, network: str, idempotency_key: str | None = None + ) -> None: + capture_calls.append( + { + "operator_token": operator_token, + "wallet_address": wallet_address, + "network": network, + "idempotency_key": idempotency_key, + } + ) + + monkeypatch.setattr( + "agentscore_commerce.identity.core.AgentScoreCore.acapture_wallet", + _mock_acapture_wallet, + ) + + async def _pricing(_ctx: Any) -> PricingResult: + return PricingResult(amount_usd=1.0) + + async def _compose_mppx(ctx: Any) -> MppxComposeOutcome: + if not ctx.request.headers.get("authorization"): + return MppxComposeOutcome(status=402, headers={"www-authenticate": 'Payment realm="t"'}) + return MppxComposeOutcome( + status=200, + rail_key="tempo", + tx_hash="0xtest", + signer_address="0xabc0000000000000000000000000000000000001", + signer_network="evm", + ) + + async def _on_settled(ctx: Any, outcome: SettleOutcome) -> dict[str, Any]: + # Actually invoke the closure to drive the AgentScoreCore call path. + if ctx.capture_wallet is not None and outcome.signer_address is not None: + await ctx.capture_wallet( + wallet_address=outcome.signer_address, + network=outcome.signer_network or "evm", + idempotency_key=outcome.tx_hash, + ) + return {"order_id": "o-1"} + + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0x" + "00" * 19 + "dE" + "aD")}, + url="https://api.example/purchase", + compute_pricing=_pricing, + compose_mppx=_compose_mppx, + on_settled=_on_settled, + gate=CheckoutGateConfig(api_key="k", require_kyc=True), + ) + result = await checkout.handle( + CheckoutRequest( + method="POST", + url="https://api.example/purchase", + headers={"authorization": "Payment ", "x-operator-token": "opc_test"}, + body={}, + raw=object(), + ), + ) + assert result.status == 200 + assert capture_calls == [ + { + "operator_token": "opc_test", + "wallet_address": "0xabc0000000000000000000000000000000000001", + "network": "evm", + "idempotency_key": "0xtest", + } + ] + + +@pytest.mark.asyncio +async def test_handle_zero_settle_mpp_carve_out() -> None: + """zero_settle_carve_out=True + 0-amount + MPP authorization → skips settle path.""" + from agentscore_commerce.checkout import Checkout, PricingResult + + async def _pricing(_ctx: Any) -> PricingResult: + return PricingResult(amount_usd=0.0) + + async def _on_settled(_ctx: Any, outcome: SettleOutcome) -> dict[str, Any]: + return {"order_id": "o-1", "tx_hash": outcome.tx_hash, "rail_key": outcome.rail_key} + + async def _compose_mppx(_ctx: Any) -> MppxComposeOutcome: + return MppxComposeOutcome(status=402, headers={"www-authenticate": 'Payment realm="t"'}) + + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0x" + "00" * 19 + "dE" + "aD")}, + url="https://api.example/purchase", + compute_pricing=_pricing, + compose_mppx=_compose_mppx, + on_settled=_on_settled, + zero_settle_carve_out=True, + ) + result = await checkout.handle( + CheckoutRequest( + method="POST", + url="https://api.example/purchase", + # MPP credential present + amount $0 → carve-out path; skips real mppx.charge. + headers={"authorization": "Payment "}, + body={"item": "wine"}, + ), + ) + # Carve-out returns 200 with tx_hash=None (no on-chain settle for $0). + assert result.status == 200 + assert result.body.get("tx_hash") is None + # The rail_key was lifted from MPP path + assert result.body.get("rail_key") in {"tempo", "tempo_mpp"} + + +def test_handle_django_returns_402_on_discovery_leg(monkeypatch: pytest.MonkeyPatch) -> None: + pytest.importorskip("django") + from django.conf import settings as dj_settings + + if not dj_settings.configured: + dj_settings.configure(DEBUG=True, ALLOWED_HOSTS=["*"]) + monkeypatch.setattr(dj_settings, "ALLOWED_HOSTS", ["*"]) + + from django.test import RequestFactory + + checkout = _minimal_checkout() + factory = RequestFactory() + req = factory.post( + "/purchase", + data=json.dumps({"item": "wine"}), + content_type="application/json", + ) + resp = checkout.handle_django(req) + assert resp.status_code == 402 + + +# ───────────────────────────────────────────────────────────────────────────── +# load_solana_fee_payer +# ───────────────────────────────────────────────────────────────────────────── + + +# ───────────────────────────────────────────────────────────────────────────── +# buildSignedUcpResponse / buildSignedJwksResponse / bootstrapUcpSigningKey +# ───────────────────────────────────────────────────────────────────────────── + + +@_contextlib.contextmanager +def _env_key(jwk_dict: dict[str, Any]) -> Any: + """Yield with UCP_SIGNING_KEY_JWK_PRIVATE set to ``jwk_dict``, restoring on exit.""" + from agentscore_commerce.identity.ucp_jwks import _reset_ucp_signing_key_cache + + _reset_ucp_signing_key_cache() + prev = os.environ.get("UCP_SIGNING_KEY_JWK_PRIVATE") + os.environ["UCP_SIGNING_KEY_JWK_PRIVATE"] = json.dumps(jwk_dict) + try: + yield + finally: + if prev is None: + os.environ.pop("UCP_SIGNING_KEY_JWK_PRIVATE", None) + else: + os.environ["UCP_SIGNING_KEY_JWK_PRIVATE"] = prev + _reset_ucp_signing_key_cache() + + +def test_bootstrap_ucp_signing_key_throws_on_malformed_env() -> None: + from agentscore_commerce.discovery import bootstrap_ucp_signing_key + from agentscore_commerce.identity.ucp_jwks import _reset_ucp_signing_key_cache + + _reset_ucp_signing_key_cache() + prev = os.environ.get("UCP_SIGNING_KEY_JWK_PRIVATE") + os.environ["UCP_SIGNING_KEY_JWK_PRIVATE"] = "not-json" + try: + with pytest.raises((ValueError, Exception)): + bootstrap_ucp_signing_key() + finally: + if prev is None: + os.environ.pop("UCP_SIGNING_KEY_JWK_PRIVATE", None) + else: + os.environ["UCP_SIGNING_KEY_JWK_PRIVATE"] = prev + _reset_ucp_signing_key_cache() + + +def test_bootstrap_ucp_signing_key_succeeds_with_valid_env() -> None: + from agentscore_commerce.discovery import bootstrap_ucp_signing_key + from agentscore_commerce.identity.ucp_jwks import generate_ucp_signing_key + + key = generate_ucp_signing_key(kid="bootstrap-test") + private_jwk = key.private_key.as_dict(private=True) + with _env_key(private_jwk): + bootstrap_ucp_signing_key() # should not raise + + +def test_build_signed_jwks_response_emits_jwk_set_json() -> None: + from agentscore_commerce.discovery import build_signed_jwks_response + from agentscore_commerce.identity.ucp_jwks import generate_ucp_signing_key + + key = generate_ucp_signing_key(kid="jwks-test") + private_jwk = key.private_key.as_dict(private=True) + with _env_key(private_jwk): + resp = build_signed_jwks_response(request_headers={"X-Request-Id": "req-jwks"}) + assert resp.status == 200 + assert resp.media_type == "application/jwk-set+json" + assert "max-age=300" in resp.headers["Cache-Control"] + assert resp.headers["X-Request-ID"] == "req-jwks" + body = json.loads(resp.content) + assert len(body["keys"]) == 1 + + +def test_build_signed_ucp_response_misconfigured_when_no_rails() -> None: + from agentscore_commerce.checkout import Checkout, PricingResult + from agentscore_commerce.discovery import build_signed_ucp_response + + async def _pricing(ctx: Any) -> PricingResult: + return PricingResult(amount_usd=1.0) + + checkout = Checkout(rails={}, url="https://x/purchase", compute_pricing=_pricing) + resp = build_signed_ucp_response( + checkout=checkout, + name="X", + well_known_ucp_url="https://x/.well-known/ucp", + services={}, + request_headers={"X-Request-Id": "req-misc"}, + ) + assert resp.status == 503 + assert "max-age=60" in resp.headers["Cache-Control"] + assert resp.headers["X-Request-ID"] == "req-misc" + body = json.loads(resp.content) + assert body["error"]["code"] == "ucp_misconfigured" + + +def test_build_signed_ucp_response_happy_path_signs_profile() -> None: + from agentscore_commerce.checkout import Checkout, PricingResult + from agentscore_commerce.discovery import build_signed_ucp_response + from agentscore_commerce.identity.ucp_jwks import generate_ucp_signing_key + + async def _pricing(ctx: Any) -> PricingResult: + return PricingResult(amount_usd=1.0) + + key = generate_ucp_signing_key(kid="ucp-test") + private_jwk = key.private_key.as_dict(private=True) + checkout = Checkout( + rails={ + "tempo": TempoRailSpec(recipient="0x" + "00" * 19 + "dE" + "aD"), + "base": X402BaseRailSpec(recipient="0x" + "00" * 19 + "dE" + "aD"), + }, + url="https://x/purchase", + compute_pricing=_pricing, + ) + with _env_key(private_jwk): + resp = build_signed_ucp_response( + checkout=checkout, + name="AgentScore Store", + well_known_ucp_url="https://x/.well-known/ucp", + services={"dev.ucp.shopping": []}, + signing_kid="ucp-test", + request_headers={"X-Request-Id": "req-ucp"}, + ) + assert resp.status == 200 + assert resp.headers["X-Request-ID"] == "req-ucp" + assert "max-age=60" in resp.headers["Cache-Control"] + body = json.loads(resp.content) + assert body["ucp"]["name"] == "AgentScore Store" + assert "signature" in body + assert body["ucp"]["payment_handlers"] + + +def test_build_signed_ucp_response_includes_solana_stripe_tempo_session() -> None: + from agentscore_commerce.checkout import Checkout, PricingResult + from agentscore_commerce.discovery import build_signed_ucp_response + from agentscore_commerce.identity.ucp_jwks import generate_ucp_signing_key + from agentscore_commerce.payment import SolanaMppRailSpec, StripeRailSpec, TempoSessionRailSpec + + async def _pricing(ctx: Any) -> PricingResult: + return PricingResult(amount_usd=1.0) + + key = generate_ucp_signing_key(kid="ucp-multi") + private_jwk = key.private_key.as_dict(private=True) + checkout = Checkout( + rails={ + "solana": SolanaMppRailSpec(recipient="SoLaNaReCiPiEnT"), + "stripe": StripeRailSpec(profile_id="profile_abc"), + "tempo_session": TempoSessionRailSpec( + recipient="0x" + "00" * 20, + escrow_contract="0x" + "11" * 20, + store=object(), + ), + }, + url="https://x/purchase", + compute_pricing=_pricing, + ) + with _env_key(private_jwk): + resp = build_signed_ucp_response( + checkout=checkout, + name="Multi-Rail", + well_known_ucp_url="https://x/.well-known/ucp", + services={}, + signing_kid="ucp-multi", + ) + assert resp.status == 200 + body = json.loads(resp.content) + keys = list(body["ucp"]["payment_handlers"].keys()) + assert any("mpp" in k or "stripe" in k for k in keys) + + +def test_default_a2a_services_returns_canonical_a2a_binding() -> None: + from agentscore_commerce.discovery.well_known import default_a2a_services + + services = default_a2a_services(agent_card_url="https://x/.well-known/agent-card.json") + assert "dev.ucp.shopping" in services + binding = services["dev.ucp.shopping"][0] + assert binding.transport == "a2a" + assert binding.endpoint == "https://x/.well-known/agent-card.json" + + +def test_well_known_cors_preflight_headers_without_request() -> None: + from agentscore_commerce.discovery import well_known_cors_preflight_headers + + headers = well_known_cors_preflight_headers() + assert headers["Access-Control-Allow-Origin"] == "*" + assert "GET" in headers["Access-Control-Allow-Methods"] + assert "Access-Control-Allow-Headers" not in headers + + +def test_well_known_cors_preflight_headers_echoes_acrh() -> None: + from agentscore_commerce.discovery import well_known_cors_preflight_headers + + headers = well_known_cors_preflight_headers( + {"Access-Control-Request-Headers": "x-foo, x-bar"}, + ) + assert headers["Access-Control-Allow-Headers"] == "x-foo, x-bar" + + +# ───────────────────────────────────────────────────────────────────────────── +# load_solana_fee_payer +# ───────────────────────────────────────────────────────────────────────────── + + +def test_load_solana_fee_payer_returns_none_on_empty() -> None: + from agentscore_commerce.payment.solana import load_solana_fee_payer + + assert load_solana_fee_payer(None) is None + assert load_solana_fee_payer("") is None + + +def test_load_solana_fee_payer_hex_input() -> None: + pytest.importorskip("solders") + from solders.keypair import Keypair + + from agentscore_commerce.payment.solana import load_solana_fee_payer + + # 64-byte hex: 32-byte secret + 32-byte public (we discard the public half). + hex_key = "01" * 64 + signer = load_solana_fee_payer(hex_key) + assert signer is not None + expected = Keypair.from_seed(bytes.fromhex(hex_key)[:32]) + assert bytes(signer) == bytes(expected) + + +def test_load_solana_fee_payer_base58_64_bytes() -> None: + pytest.importorskip("solders") + pytest.importorskip("base58") + import base58 + from solders.keypair import Keypair + + from agentscore_commerce.payment.solana import load_solana_fee_payer + + kp = Keypair() + full_bytes = bytes(kp) # solders Keypair serializes to 64 bytes (secret+public) + encoded = base58.b58encode(full_bytes).decode() + signer = load_solana_fee_payer(encoded) + assert signer is not None + assert bytes(signer) == full_bytes + + +def test_load_solana_fee_payer_base58_32_bytes_seed() -> None: + pytest.importorskip("solders") + pytest.importorskip("base58") + import base58 + from solders.keypair import Keypair + + from agentscore_commerce.payment.solana import load_solana_fee_payer + + seed = bytes(range(32)) + encoded = base58.b58encode(seed).decode() + signer = load_solana_fee_payer(encoded) + assert signer is not None + expected = Keypair.from_seed(seed) + assert bytes(signer) == bytes(expected) + + +def test_load_solana_fee_payer_base58_wrong_length_raises() -> None: + pytest.importorskip("solders") + pytest.importorskip("base58") + import base58 + + from agentscore_commerce.payment.solana import load_solana_fee_payer + + encoded = base58.b58encode(b"\x00" * 16).decode() # 16 bytes; invalid + with pytest.raises(ValueError, match="must decode to 32 or 64 bytes"): + load_solana_fee_payer(encoded) + + +# ───────────────────────────────────────────────────────────────────────────── +# well_known_preflight_response +# ───────────────────────────────────────────────────────────────────────────── + + +def test_well_known_preflight_response_204_with_cors_headers() -> None: + from agentscore_commerce.discovery import ( + WellKnownPreflightResponse, + well_known_preflight_response, + ) + + resp = well_known_preflight_response() + assert isinstance(resp, WellKnownPreflightResponse) + assert resp.status == 204 + assert resp.content == b"" + assert resp.headers["Access-Control-Allow-Origin"] == "*" + assert "GET" in resp.headers["Access-Control-Allow-Methods"] + assert "OPTIONS" in resp.headers["Access-Control-Allow-Methods"] + + +def test_well_known_preflight_response_echoes_request_headers() -> None: + from agentscore_commerce.discovery import well_known_preflight_response + + resp = well_known_preflight_response({"Access-Control-Request-Headers": "x-foo, x-bar"}) + assert resp.headers["Access-Control-Allow-Headers"] == "x-foo, x-bar" + + +# ───────────────────────────────────────────────────────────────────────────── +# build_merchant_index_json +# ───────────────────────────────────────────────────────────────────────────── + + +def test_build_merchant_index_json_core_fields() -> None: + from agentscore_commerce.discovery import build_merchant_index_json + + body = build_merchant_index_json( + name="AgentScore Store", + description="Wine and merch for agents.", + docs={"llms": "https://x/llms.txt", "openapi": "https://x/openapi.json"}, + endpoints={"GET /catalog": "List products."}, + supported_rails=["tempo", "x402-base"], + ) + assert body["name"] == "AgentScore Store" + assert body["audience"] == "agents" + assert body["supported_rails"] == ["tempo", "x402-base"] + assert body["docs"]["llms"] == "https://x/llms.txt" + assert body["endpoints"]["GET /catalog"] == "List products." + + +def test_build_merchant_index_json_extra_merges() -> None: + from agentscore_commerce.discovery import build_merchant_index_json + + body = build_merchant_index_json( + name="X", + description="Y", + docs={}, + endpoints={}, + supported_rails=[], + extra={"compliance": {"min_age": 21}, "website": "https://x.example"}, + ) + assert body["compliance"] == {"min_age": 21} + assert body["website"] == "https://x.example" + + +# ───────────────────────────────────────────────────────────────────────────── +# x_service_info_extension + x_payment_info_from_checkout (new openapi helpers) +# ───────────────────────────────────────────────────────────────────────────── + + +def test_x_service_info_extension_minimal() -> None: + from agentscore_commerce.discovery import x_service_info_extension + + ext = x_service_info_extension(categories=["commerce", "wine"]) + assert ext == {"x-service-info": {"categories": ["commerce", "wine"]}} + + +def test_x_service_info_extension_with_docs() -> None: + from agentscore_commerce.discovery import x_service_info_extension + + ext = x_service_info_extension( + categories=["commerce"], + docs={"human": "https://x.example/about"}, + ) + assert ext["x-service-info"]["docs"] == {"human": "https://x.example/about"} + + +def test_x_payment_info_extension_emits_auth_mode_payment_and_description() -> None: + from agentscore_commerce.discovery import ( + XPaymentInfoFixedPrice, + x_payment_info_extension, + ) + + ext = x_payment_info_extension( + price=XPaymentInfoFixedPrice(currency="USD", amount="5.00"), + protocols=[{"x402": {}}], + description="Per-purchase fee.", + ) + block = ext["x-payment-info"] + assert block["authMode"] == "payment" + assert block["description"] == "Per-purchase fee." + assert block["price"] == {"mode": "fixed", "currency": "USD", "amount": "5.00"} + + +def test_x_payment_info_extension_dynamic_price() -> None: + from agentscore_commerce.discovery import ( + XPaymentInfoDynamicPrice, + x_payment_info_extension, + ) + + ext = x_payment_info_extension( + price=XPaymentInfoDynamicPrice(currency="USD", min="0.01", max="5.00"), + protocols=[], + ) + assert ext["x-payment-info"]["price"] == { + "mode": "dynamic", + "currency": "USD", + "min": "0.01", + "max": "5.00", + } + + +def test_x_payment_info_from_checkout_lists_protocols_per_rail() -> None: + from agentscore_commerce.discovery import ( + XPaymentInfoFixedPrice, + x_payment_info_from_checkout, + ) + + # Reuse _minimal_checkout from the file so the rails dict matches the test fixture. + checkout = _minimal_checkout() + ext = x_payment_info_from_checkout( + checkout=checkout, + price=XPaymentInfoFixedPrice(currency="USD", amount="1.00"), + description="Per-call fee.", + ) + block = ext["x-payment-info"] + assert block["authMode"] == "payment" + assert block["description"] == "Per-call fee." + # _minimal_checkout has a tempo rail; protocol entry is `{"mpp": {"method": "tempo", "intent": "charge", ...}}`. + assert any(p.get("mpp", {}).get("method") == "tempo" for p in block["protocols"]) + + +def test_x_payment_info_from_checkout_covers_all_rail_types() -> None: + from agentscore_commerce.checkout import Checkout, PricingResult + from agentscore_commerce.discovery import ( + XPaymentInfoFixedPrice, + x_payment_info_from_checkout, + ) + from agentscore_commerce.payment import SolanaMppRailSpec, StripeRailSpec + + async def _pricing(ctx: Any) -> PricingResult: + return PricingResult(amount_usd=1.0) + + checkout = Checkout( + rails={ + "tempo": TempoRailSpec(recipient="0x" + "00" * 20), + "base": X402BaseRailSpec(recipient="0x" + "00" * 20), + "stripe": StripeRailSpec(profile_id="profile_abc"), + "solana": SolanaMppRailSpec(recipient="SoLaNaReCiPiEnT", token="EPjFWdd5..."), + }, + url="https://x/purchase", + compute_pricing=_pricing, + ) + ext = x_payment_info_from_checkout( + checkout=checkout, + price=XPaymentInfoFixedPrice(currency="USD", amount="1.00"), + ) + protos = ext["x-payment-info"]["protocols"] + methods = [p.get("mpp", {}).get("method") or "x402" for p in protos] + assert "stripe" in methods + assert "tempo" in methods + assert "solana" in methods + assert "x402" in methods + # Solana entry should include the `currency` from token + solana_entry = next(p["mpp"] for p in protos if p.get("mpp", {}).get("method") == "solana") + assert solana_entry["currency"] == "EPjFWdd5..." + + +def test_x_payment_info_from_checkout_merges_protocol_extras() -> None: + from agentscore_commerce.discovery import ( + XPaymentInfoFixedPrice, + x_payment_info_from_checkout, + ) + + checkout = _minimal_checkout() + ext = x_payment_info_from_checkout( + checkout=checkout, + price=XPaymentInfoFixedPrice(currency="USD", amount="1.00"), + protocol_extras={"tempo": {"client_command": "agentscore-pay pay --chain tempo"}}, + ) + tempo_entry = next( + p["mpp"] for p in ext["x-payment-info"]["protocols"] if p.get("mpp", {}).get("method") == "tempo" + ) + assert tempo_entry["client_command"] == "agentscore-pay pay --chain tempo" diff --git a/tests/test_signer_match.py b/tests/test_signer_match.py index 6cfd08a..9f01edd 100644 --- a/tests/test_signer_match.py +++ b/tests/test_signer_match.py @@ -196,7 +196,7 @@ def test_check_raises_token_denied_on_401_expired() -> None: client.check(operator_token="opc_expired") except TokenDeniedError as err: assert err.code == "token_expired" - assert err.next_steps == {"action": "deliver_verify_url_and_poll"} + assert err.body.get("next_steps") == {"action": "deliver_verify_url_and_poll"} else: pytest.fail("expected TokenDeniedError") @@ -210,7 +210,7 @@ def test_check_raises_token_denied_on_401_revoked() -> None: client.check(operator_token="opc_revoked") except TokenDeniedError as err: assert err.code == "token_expired" - assert err.next_steps is None + assert "next_steps" not in err.body else: pytest.fail("expected TokenDeniedError") diff --git a/uv.lock b/uv.lock index 01471ff..f9594de 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [[package]] name = "agentscore-commerce" -version = "1.8.1" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "agentscore-py" },