From dec3e198dd673c1c13d23cfc0f479c37933b0f63 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 07:09:22 -0700 Subject: [PATCH 01/24] feat(identity/policy): validate_shipping_against_policy helper (Tier 1 lift B) Collapses the universal goods-merchant boilerplate if not shipping_country_allowed(country, policy): raise CheckoutValidationError(code='unsupported_jurisdiction', ...) if not shipping_state_allowed(state, country, policy): raise CheckoutValidationError(code='unsupported_jurisdiction', ...) into one helper call. Used by per_product_policy_merchant example + core/store/purchase.py + martin-estate (each currently inlines the same 2-check pattern in preValidate). Default messages are neutral (don't assume regulatory reason - could be operational / commercial). country_message / state_message override verbatim. error_code / error_action override the canonical denial codes. Tests: 7 new cases (null policy, no-allowlist, country deny, state deny, product_name in message, custom messages, custom code/action). Suite green at 95.13 percent coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/identity/policy.py | 54 ++++++++++++++++ tests/test_policy.py | 85 ++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/agentscore_commerce/identity/policy.py b/agentscore_commerce/identity/policy.py index ce042c2..f367b5f 100644 --- a/agentscore_commerce/identity/policy.py +++ b/agentscore_commerce/identity/policy.py @@ -168,6 +168,59 @@ def shipping_state_allowed(state: str, country: str, policy: Mapping[str, Any] | return state.upper() in {s.upper() for s in states} +def validate_shipping_against_policy( + *, + country: str, + state: str, + policy: Mapping[str, Any] | None, + product_name: str | None = None, + error_code: str = "unsupported_jurisdiction", + error_action: str = "change_shipping_state", + country_message: str | None = None, + state_message: str | None = None, +) -> None: + """Raise :class:`CheckoutValidationError` when shipping isn't allowed by the policy. + + One-call replacement for the ``if not shipping_country_allowed(...): raise`` + + ``if not shipping_state_allowed(...): raise`` boilerplate every goods + merchant writes in their ``pre_validate`` hook. + + ``policy`` is a :class:`PolicyBlock`-shaped mapping (or ``None``); NULL + policy means "ship anywhere" and the function is a no-op. The reason a + location is excluded is **merchant-defined**: it might be regulatory + (regulated goods + state allowlist), operational (no fulfillment partner), + or commercial (fragility, fraud-rate-by-region, etc.) — the helper + doesn't assume. + + ``product_name`` is the user-facing item name surfaced in the error + message ("Cannot ship 'Wine 2020' to NY ..."). Omit for a generic message. + + ``error_code`` and ``error_action`` let merchants override the canonical + denial codes if their consumer agents expect different shapes. + + ``country_message`` / ``state_message`` override the default messages + verbatim (use these when the default phrasing isn't right for your + consumer agents — e.g. you want to surface the regulatory reason + explicitly, or you want the message in a different language). + """ + # Local import dodges the circular: checkout depends on identity.policy. + from agentscore_commerce.checkout import CheckoutValidationError + + item = f"'{product_name}'" if product_name else "this item" + if not shipping_country_allowed(country, policy): + raise CheckoutValidationError( + code=error_code, + message=country_message or f"We can't ship {item} to {country.upper() or ''}.", + action=error_action, + ) + if not shipping_state_allowed(state, country, policy): + raise CheckoutValidationError( + code=error_code, + message=state_message or f"We can't ship {item} to {state.upper() or ''}.", + action=error_action, + ) + + __all__ = [ "EnforcementMode", "GateResult", @@ -177,4 +230,5 @@ def shipping_state_allowed(state: str, country: str, policy: Mapping[str, Any] | "run_gate_with_enforcement", "shipping_country_allowed", "shipping_state_allowed", + "validate_shipping_against_policy", ] diff --git a/tests/test_policy.py b/tests/test_policy.py index 04dfa18..8a422b9 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -15,6 +15,7 @@ run_gate_with_enforcement, shipping_country_allowed, shipping_state_allowed, + validate_shipping_against_policy, ) # ── shipping helpers ───────────────────────────────────────────────────────── @@ -158,3 +159,87 @@ def test_module_exports_public_surface() -> None: "shipping_state_allowed", ): assert hasattr(policy_mod, name), name + + +# ── validate_shipping_against_policy ───────────────────────────────────────── + + +def test_validate_shipping_no_op_on_null_policy() -> None: + # No raise — ship anywhere when policy is None. + validate_shipping_against_policy(country="AQ", state="", policy=None) + + +def test_validate_shipping_no_op_when_allowlist_empty() -> None: + # Empty allowlist == no restriction; policy with other fields is fine. + validate_shipping_against_policy(country="JP", state="", policy={"require_kyc": True}) + + +def test_validate_shipping_raises_on_disallowed_country() -> None: + from agentscore_commerce.checkout import CheckoutValidationError + + with pytest.raises(CheckoutValidationError) as exc: + validate_shipping_against_policy(country="JP", state="", policy={"allowed_shipping_countries": ["US"]}) + assert exc.value.code == "unsupported_jurisdiction" + assert "JP" in exc.value.message + assert exc.value.action == "change_shipping_state" + + +def test_validate_shipping_raises_on_disallowed_state() -> None: + from agentscore_commerce.checkout import CheckoutValidationError + + policy = {"allowed_shipping_countries": ["US"], "allowed_shipping_states": ["CA", "NY"]} + with pytest.raises(CheckoutValidationError) as exc: + validate_shipping_against_policy(country="US", state="UT", policy=policy) + assert exc.value.code == "unsupported_jurisdiction" + assert "UT" in exc.value.message + + +def test_validate_shipping_product_name_appears_in_message() -> None: + from agentscore_commerce.checkout import CheckoutValidationError + + with pytest.raises(CheckoutValidationError) as exc: + validate_shipping_against_policy( + country="JP", + state="", + policy={"allowed_shipping_countries": ["US"]}, + product_name="Reserve Cabernet", + ) + assert "Reserve Cabernet" in exc.value.message + + +def test_validate_shipping_custom_messages_override_defaults() -> None: + from agentscore_commerce.checkout import CheckoutValidationError + + with pytest.raises(CheckoutValidationError) as exc_country: + validate_shipping_against_policy( + country="JP", + state="", + policy={"allowed_shipping_countries": ["US"]}, + country_message="Sorry, regulations.", + ) + assert exc_country.value.message == "Sorry, regulations." + + policy = {"allowed_shipping_countries": ["US"], "allowed_shipping_states": ["CA"]} + with pytest.raises(CheckoutValidationError) as exc_state: + validate_shipping_against_policy( + country="US", + state="UT", + policy=policy, + state_message="Fulfillment partner doesn't cover that area.", + ) + assert exc_state.value.message == "Fulfillment partner doesn't cover that area." + + +def test_validate_shipping_custom_code_and_action() -> None: + from agentscore_commerce.checkout import CheckoutValidationError + + with pytest.raises(CheckoutValidationError) as exc: + validate_shipping_against_policy( + country="JP", + state="", + policy={"allowed_shipping_countries": ["US"]}, + error_code="ships_us_only", + error_action="contact_support", + ) + assert exc.value.code == "ships_us_only" + assert exc.value.action == "contact_support" From 6e75da797a36abb7d3fe71195ead6920ba12884a Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 07:18:56 -0700 Subject: [PATCH 02/24] feat(checkout): pricing_result factory (Tier 1 lift C) Collapses the universal US-commerce boilerplate block = build_pricing_block(subtotal_cents=..., tax_cents=..., ...) return PricingResult(amount_usd=total_cents/100, block=block, ...) into one helper call. Used by core/store, martin-estate, and the multi_rail_merchant example (each currently inlines the same dance in compute_pricing). - subtotal_cents + tax_cents + shipping_cents derive amount_usd. - tax_rate / tax_state attach to the block for the 402 body. - Passthrough mode (amount_usd only) for API merchants with no tax. - Explicit amount_usd overrides the cents-derived value when both passed. Tests: 7 cases. pricing_result is also re-exported at the top level. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/__init__.py | 2 + agentscore_commerce/checkout.py | 66 +++++++++++++++++++++++++++++++++ tests/test_seamless_helpers.py | 66 +++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/agentscore_commerce/__init__.py b/agentscore_commerce/__init__.py index 1538f9a..3576bb2 100644 --- a/agentscore_commerce/__init__.py +++ b/agentscore_commerce/__init__.py @@ -25,6 +25,7 @@ PricingResult, SettleOutcome, format_pydantic_errors, + pricing_result, validation_envelope, validation_response_aiohttp, validation_response_django, @@ -151,6 +152,7 @@ "load_ucp_signing_key_from_env", "make_mppx_compose_hook", "mpp_payment_handler", + "pricing_result", "read_x402_payment_header", "sign_ucp_profile", "stripe_spt_payment_handler", diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index 3c43e5d..65a7cb3 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -204,6 +204,72 @@ class PricingResult: merchant wants the agent to see in the challenge body.""" +def pricing_result( + *, + subtotal_cents: int | None = None, + tax_cents: int | None = None, + shipping_cents: int | None = None, + tax_rate: float | None = None, + tax_state: str | None = None, + currency: str = "USD", + amount_usd: float | None = None, + product: dict[str, str] | None = None, + body_extras: dict[str, Any] | None = None, +) -> PricingResult: + """Build a :class:`PricingResult` from cents-denominated inputs. + + Saves the ``PricingResult(amount_usd=..., block=build_pricing_block(...))`` + dance every US-commerce merchant repeats. When ``subtotal_cents`` is set: + + * ``amount_usd`` is derived from ``(subtotal + tax + shipping) / 100`` + unless explicitly provided. + * A :class:`PricingBlock` is built via :func:`build_pricing_block` and + attached to the result's ``block`` field. + + When ``subtotal_cents`` is omitted, the function passes through to the + raw :class:`PricingResult` constructor; ``amount_usd`` is then required. + + Use this in ``compute_pricing`` hooks instead of hand-rolling:: + + async def _compute_pricing(ctx: CheckoutContext) -> PricingResult: + return pricing_result( + subtotal_cents=25000, + tax_cents=2000, + tax_rate=0.08, + tax_state="CA", + ) + """ + from agentscore_commerce.challenge import build_pricing_block + + if subtotal_cents is not None: + total_cents = subtotal_cents + (tax_cents or 0) + (shipping_cents or 0) + derived_amount = total_cents / 100 if amount_usd is None else amount_usd + block = build_pricing_block( + subtotal_cents=subtotal_cents, + tax_cents=tax_cents or 0, + shipping_cents=shipping_cents, + tax_rate=tax_rate, + tax_state=tax_state, + currency=currency, + ) + return PricingResult( + amount_usd=derived_amount, + currency=currency, + block=block, + product=product, + body_extras=body_extras, + ) + if amount_usd is None: + msg = "pricing_result requires either `subtotal_cents` or `amount_usd`." + raise ValueError(msg) + return PricingResult( + amount_usd=amount_usd, + currency=currency, + product=product, + body_extras=body_extras, + ) + + @dataclass class CheckoutContext: """In-flight state passed to every hook in the Checkout flow.""" diff --git a/tests/test_seamless_helpers.py b/tests/test_seamless_helpers.py index 82fc091..29a523c 100644 --- a/tests/test_seamless_helpers.py +++ b/tests/test_seamless_helpers.py @@ -797,6 +797,72 @@ async def _compose_mppx(ctx: Any) -> MppxComposeOutcome: assert capture_calls == [{"available": True, "tx": "0xtest"}] +# ───────────────────────────────────────────────────────────────────────────── +# pricing_result factory (Tier 1 lift C) +# ───────────────────────────────────────────────────────────────────────────── + + +def test_pricing_result_derives_amount_from_cents() -> None: + from agentscore_commerce import pricing_result + + pr = pricing_result(subtotal_cents=25000, tax_cents=2000) + assert pr.amount_usd == 270.0 + assert pr.currency == "USD" + assert pr.block is not None + assert pr.block.subtotal == "250.00" + assert pr.block.tax == "20.00" + + +def test_pricing_result_includes_shipping_when_set() -> None: + from agentscore_commerce import pricing_result + + pr = pricing_result(subtotal_cents=25000, tax_cents=2000, shipping_cents=999) + assert pr.amount_usd == 279.99 + + +def test_pricing_result_tax_rate_and_state_attach_to_block() -> None: + from agentscore_commerce import pricing_result + + pr = pricing_result(subtotal_cents=25000, tax_cents=2000, tax_rate=0.08, tax_state="CA") + assert pr.block is not None + assert pr.block.tax_rate == 0.08 + assert pr.block.tax_state == "CA" + + +def test_pricing_result_passthrough_amount_usd() -> None: + from agentscore_commerce import pricing_result + + pr = pricing_result(amount_usd=0.01) + assert pr.amount_usd == 0.01 + assert pr.block is None + + +def test_pricing_result_explicit_amount_overrides_subtotal_derivation() -> None: + from agentscore_commerce import pricing_result + + pr = pricing_result(subtotal_cents=25000, tax_cents=2000, amount_usd=999.99) + assert pr.amount_usd == 999.99 + assert pr.block is not None + assert pr.block.subtotal == "250.00" + + +def test_pricing_result_raises_when_no_amount_source() -> None: + from agentscore_commerce import pricing_result + + with pytest.raises(ValueError, match=r"subtotal_cents.*amount_usd"): + pricing_result(currency="USD") + + +def test_pricing_result_propagates_product_and_body_extras() -> None: + from agentscore_commerce import pricing_result + + product = {"id": "sku_1", "name": "Test"} + extras = {"redemption_code_applied": "WELCOME"} + pr = pricing_result(subtotal_cents=100, product=product, body_extras=extras) + assert pr.product == product + assert pr.body_extras == extras + + @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 f68970c49c3ecb8ad0525e9a72b0c4a2db93c291 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 07:30:20 -0700 Subject: [PATCH 03/24] feat(checkout): Checkout(discovery_probe=...) auto-routing (Tier 2 lift D) When a Checkout is constructed with discovery_probe=DiscoveryProbeConfig(...), any empty-body POST without a payment header short-circuits with a sample 402 advertising the merchant's payment shape. Saves vendors the is_discovery_probe_request + build_discovery_probe_response dance every crawler-friendly merchant repeats. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/__init__.py | 2 + agentscore_commerce/checkout.py | 62 ++++++++++++++++++ tests/test_seamless_helpers.py | 111 ++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+) diff --git a/agentscore_commerce/__init__.py b/agentscore_commerce/__init__.py index 3576bb2..0341bde 100644 --- a/agentscore_commerce/__init__.py +++ b/agentscore_commerce/__init__.py @@ -21,6 +21,7 @@ CheckoutRequest, CheckoutResult, CheckoutValidationError, + DiscoveryProbeConfig, MppxComposeOutcome, PricingResult, SettleOutcome, @@ -117,6 +118,7 @@ "CreateSessionOnMissing", "DenialCode", "DenialReason", + "DiscoveryProbeConfig", "MppxComposeOutcome", "PaymentSigner", "PolicyBlock", diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index 65a7cb3..edfdb94 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -67,6 +67,7 @@ from __future__ import annotations import inspect +import json import uuid from collections.abc import Awaitable, Callable from dataclasses import dataclass, field @@ -183,6 +184,31 @@ class CheckoutRequest: this through unchanged.""" +@dataclass +class DiscoveryProbeConfig: + """Auto-route discovery probes inside :meth:`Checkout.handle`. + + When set on the Checkout, an empty-body POST without any payment header + short-circuits to a sample 402 advertising the merchant's discovery shape + for crawlers (``awal x402 details``, x402-proxy, x402scan, ...). The + probe DOES NOT settle anything; it's an SEO-shaped advertisement. + + Per-rail real-recipient discovery still happens via the regular 402 emit + path on a non-probe request. Sample data here is intentionally minimal + (single rail, single recipient) since crawlers only need the shape. + """ + + realm: str + sample_rail: str + sample_amount_usd: float + sample_recipient: str + intent: str = "charge" + ttl_seconds: int = 300 + docs_url: str | None = None + message: str | None = None + x402_sample: Any = None # X402SampleProbe, optional + + @dataclass class PricingResult: """Output of :attr:`Checkout.compute_pricing`; per-request pricing.""" @@ -577,6 +603,7 @@ def __init__( zero_settle_carve_out: bool = False, gate: CheckoutGateConfig | None = None, discovery_extensions: dict[str, Any] | None = None, + discovery_probe: DiscoveryProbeConfig | None = None, ) -> None: # Auto-derive x402_server when not supplied: rails has an X402BaseRailSpec # → lazy-init via SDK helper. Merchants only pass CDP creds (or omit @@ -640,6 +667,7 @@ def __init__( self.zero_settle_carve_out = zero_settle_carve_out self.gate = gate self.discovery_extensions = discovery_extensions + self.discovery_probe = discovery_probe """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 @@ -754,6 +782,40 @@ async def handle(self, request: CheckoutRequest) -> CheckoutResult: reference_id = await self._mint_reference_id(request) ctx = CheckoutContext(request=request, reference_id=reference_id) + # Discovery probe (optional): empty-body POST without a payment header + # → sample 402 advertising the merchant's shape for crawlers. Routes + # AHEAD of pre_validate so probe responses don't trip on body-validation + # rules (probes carry no business body). + if self.discovery_probe is not None: + from agentscore_commerce.discovery import ( + build_discovery_probe_response, + is_discovery_probe_request, + ) + + auth = request.headers.get("authorization") or request.headers.get("Authorization") + body_text = json.dumps(request.body) if request.body else "" + if await is_discovery_probe_request(request.method, auth, body_text): + cfg = self.discovery_probe + probe = build_discovery_probe_response( + realm=cfg.realm, + sample_rail=cfg.sample_rail, + sample_amount_usd=cfg.sample_amount_usd, + sample_recipient=cfg.sample_recipient, + intent=cfg.intent, + ttl_seconds=cfg.ttl_seconds, + docs_url=cfg.docs_url, + message=cfg.message, + x402_sample=cfg.x402_sample, + ) + return CheckoutResult( + status=probe.status, + body=json.loads(probe.body), + headers=probe.headers, + reference_id=ctx.reference_id, + settled=False, + settle_phase="discovery_probe", + ) + # 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 diff --git a/tests/test_seamless_helpers.py b/tests/test_seamless_helpers.py index 29a523c..bffb161 100644 --- a/tests/test_seamless_helpers.py +++ b/tests/test_seamless_helpers.py @@ -797,6 +797,117 @@ async def _compose_mppx(ctx: Any) -> MppxComposeOutcome: assert capture_calls == [{"available": True, "tx": "0xtest"}] +# ───────────────────────────────────────────────────────────────────────────── +# Checkout discovery_probe (Tier 2 lift D) +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_checkout_discovery_probe_emits_sample_402_on_empty_body() -> None: + from agentscore_commerce import ( + Checkout, + CheckoutRequest, + DiscoveryProbeConfig, + PricingResult, + ) + from agentscore_commerce.payment.rail_spec import TempoRailSpec + + async def _pricing(_ctx: Any) -> PricingResult: + return PricingResult(amount_usd=0.01) + + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0x" + "00" * 19 + "dE")}, + url="https://api.example/search", + compute_pricing=_pricing, + discovery_probe=DiscoveryProbeConfig( + realm="api.example", + sample_rail="tempo-mainnet", + sample_amount_usd=0.01, + sample_recipient="0xRecipient", + ), + ) + result = await checkout.handle( + CheckoutRequest(method="POST", url="https://api.example/search", headers={}, body={}), + ) + assert result.status == 402 + assert result.settle_phase == "discovery_probe" + # Probe body carries the discovery marker + a payment-required error + assert result.body.get("discovery") is True + assert result.body.get("error", {}).get("code") == "payment_required" + + +@pytest.mark.asyncio +async def test_checkout_discovery_probe_skipped_when_payment_header_present() -> None: + from agentscore_commerce import ( + Checkout, + CheckoutRequest, + DiscoveryProbeConfig, + PricingResult, + ) + from agentscore_commerce.payment.rail_spec import TempoRailSpec + + async def _pricing(_ctx: Any) -> PricingResult: + return PricingResult(amount_usd=0.01) + + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0x" + "00" * 19 + "dE")}, + url="https://api.example/search", + compute_pricing=_pricing, + discovery_probe=DiscoveryProbeConfig( + realm="api.example", + sample_rail="tempo-mainnet", + sample_amount_usd=0.01, + sample_recipient="0xRecipient", + ), + ) + result = await checkout.handle( + CheckoutRequest( + method="POST", + url="https://api.example/search", + headers={"authorization": "Payment "}, + body={}, + ), + ) + # With a payment header, falls through to regular handling (not the probe path). + assert result.settle_phase != "discovery_probe" + + +@pytest.mark.asyncio +async def test_checkout_discovery_probe_skipped_when_body_nonempty() -> None: + from agentscore_commerce import ( + Checkout, + CheckoutRequest, + DiscoveryProbeConfig, + PricingResult, + ) + from agentscore_commerce.payment.rail_spec import TempoRailSpec + + async def _pricing(_ctx: Any) -> PricingResult: + return PricingResult(amount_usd=0.01) + + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0x" + "00" * 19 + "dE")}, + url="https://api.example/search", + compute_pricing=_pricing, + discovery_probe=DiscoveryProbeConfig( + realm="api.example", + sample_rail="tempo-mainnet", + sample_amount_usd=0.01, + sample_recipient="0xRecipient", + ), + ) + result = await checkout.handle( + CheckoutRequest( + method="POST", + url="https://api.example/search", + headers={}, + body={"query": "test"}, + ), + ) + # Real business body → not a probe; falls through to regular 402 emit. + assert result.settle_phase != "discovery_probe" + + # ───────────────────────────────────────────────────────────────────────────── # pricing_result factory (Tier 1 lift C) # ───────────────────────────────────────────────────────────────────────────── From 33ae4d8f40da9b9871e8fbc58760be2388429ce7 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 07:35:07 -0700 Subject: [PATCH 04/24] feat(discovery): signed_response_ wrappers (Tier 2 lift A) 5 wrappers (fastapi, flask, django, aiohttp, sanic) that convert the framework-neutral SignedDiscoveryResponse / WellKnownPreflightResponse into the framework's native Response. Saves merchants the 4-line Response(content=..., media_type=..., headers=..., status_code=...) shim they otherwise hand-roll on every /.well-known/{ucp,jwks.json} route. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/discovery/__init__.py | 10 +++ agentscore_commerce/discovery/well_known.py | 68 +++++++++++++++++ tests/test_seamless_helpers.py | 84 +++++++++++++++++++++ 3 files changed, 162 insertions(+) diff --git a/agentscore_commerce/discovery/__init__.py b/agentscore_commerce/discovery/__init__.py index fef765b..9063b34 100644 --- a/agentscore_commerce/discovery/__init__.py +++ b/agentscore_commerce/discovery/__init__.py @@ -62,6 +62,11 @@ build_signed_jwks_response, build_signed_ucp_response, default_a2a_services, + signed_response_aiohttp, + signed_response_django, + signed_response_fastapi, + signed_response_flask, + signed_response_sanic, well_known_cors_preflight_headers, well_known_preflight_response, ) @@ -122,6 +127,11 @@ "llms_txt_payment_section", "purchase_mode_note", "sample_x402_accept_for_network", + "signed_response_aiohttp", + "signed_response_django", + "signed_response_fastapi", + "signed_response_flask", + "signed_response_sanic", "siwx_security_scheme", "standard_endpoint_descriptions", "well_known_cors_preflight_headers", diff --git a/agentscore_commerce/discovery/well_known.py b/agentscore_commerce/discovery/well_known.py index d520020..0bd2793 100644 --- a/agentscore_commerce/discovery/well_known.py +++ b/agentscore_commerce/discovery/well_known.py @@ -323,6 +323,69 @@ def bootstrap_ucp_signing_key(*, default_kid: str = "merchant-default") -> None: load_ucp_signing_key_from_env(default_kid=default_kid) +def signed_response_fastapi(resp: SignedDiscoveryResponse | WellKnownPreflightResponse) -> Any: + """Wrap a neutral discovery response in a FastAPI / Starlette ``Response``.""" + from starlette.responses import Response + + media_type = resp.media_type if isinstance(resp, SignedDiscoveryResponse) else "application/json" + return Response( + content=resp.content, + media_type=media_type, + headers=resp.headers, + status_code=resp.status, + ) + + +def signed_response_flask(resp: SignedDiscoveryResponse | WellKnownPreflightResponse) -> Any: + """Wrap a neutral discovery response in a Flask ``Response``.""" + from flask import Response + + media_type = resp.media_type if isinstance(resp, SignedDiscoveryResponse) else "application/json" + return Response( + response=resp.content, + status=resp.status, + headers=resp.headers, + mimetype=media_type, + ) + + +def signed_response_django(resp: SignedDiscoveryResponse | WellKnownPreflightResponse) -> Any: + """Wrap a neutral discovery response in a Django ``HttpResponse``.""" + from django.http import HttpResponse + + media_type = resp.media_type if isinstance(resp, SignedDiscoveryResponse) else "application/json" + out = HttpResponse(content=resp.content, content_type=media_type, status=resp.status) + for k, v in resp.headers.items(): + out[k] = v + return out + + +def signed_response_aiohttp(resp: SignedDiscoveryResponse | WellKnownPreflightResponse) -> Any: + """Wrap a neutral discovery response in an aiohttp ``web.Response``.""" + from aiohttp import web + + media_type = resp.media_type if isinstance(resp, SignedDiscoveryResponse) else "application/json" + return web.Response( + body=resp.content, + status=resp.status, + headers=resp.headers, + content_type=media_type, + ) + + +def signed_response_sanic(resp: SignedDiscoveryResponse | WellKnownPreflightResponse) -> Any: + """Wrap a neutral discovery response in a Sanic ``HTTPResponse``.""" + from sanic.response import HTTPResponse + + media_type = resp.media_type if isinstance(resp, SignedDiscoveryResponse) else "application/json" + return HTTPResponse( + body=resp.content, + status=resp.status, + headers=resp.headers, + content_type=media_type, + ) + + __all__ = [ "SignedDiscoveryResponse", "WellKnownPreflightResponse", @@ -330,6 +393,11 @@ def bootstrap_ucp_signing_key(*, default_kid: str = "merchant-default") -> None: "build_signed_jwks_response", "build_signed_ucp_response", "default_a2a_services", + "signed_response_aiohttp", + "signed_response_django", + "signed_response_fastapi", + "signed_response_flask", + "signed_response_sanic", "well_known_cors_preflight_headers", "well_known_preflight_response", ] diff --git a/tests/test_seamless_helpers.py b/tests/test_seamless_helpers.py index bffb161..df9729e 100644 --- a/tests/test_seamless_helpers.py +++ b/tests/test_seamless_helpers.py @@ -1472,6 +1472,90 @@ def test_well_known_preflight_response_echoes_request_headers() -> None: assert resp.headers["Access-Control-Allow-Headers"] == "x-foo, x-bar" +# ───────────────────────────────────────────────────────────────────────────── +# signed_response_ wrappers (Tier 2 lift A) +# ───────────────────────────────────────────────────────────────────────────── + + +def _neutral_signed() -> object: + from agentscore_commerce.discovery import SignedDiscoveryResponse + + return SignedDiscoveryResponse( + content=b'{"ok": true}', + media_type="application/json", + headers={"Cache-Control": "public, max-age=60"}, + status=200, + ) + + +def test_signed_response_fastapi_wraps_neutral_payload() -> None: + from agentscore_commerce.discovery import signed_response_fastapi + + out = signed_response_fastapi(_neutral_signed()) + assert out.status_code == 200 + assert out.headers["cache-control"] == "public, max-age=60" + assert out.media_type == "application/json" + assert out.body == b'{"ok": true}' + + +def test_signed_response_fastapi_handles_preflight() -> None: + from agentscore_commerce.discovery import ( + signed_response_fastapi, + well_known_preflight_response, + ) + + out = signed_response_fastapi(well_known_preflight_response()) + assert out.status_code == 204 + assert out.body == b"" + assert out.headers["access-control-allow-origin"] == "*" + + +def test_signed_response_flask_wraps_neutral_payload() -> None: + from agentscore_commerce.discovery import signed_response_flask + + out = signed_response_flask(_neutral_signed()) + assert out.status_code == 200 + assert out.mimetype == "application/json" + assert out.get_data() == b'{"ok": true}' + assert out.headers.get("Cache-Control") == "public, max-age=60" + + +def test_signed_response_django_wraps_neutral_payload() -> None: + import django + from django.conf import settings + + if not settings.configured: + settings.configure(DEBUG=False, ALLOWED_HOSTS=["*"], DEFAULT_CHARSET="utf-8") + django.setup() + + from agentscore_commerce.discovery import signed_response_django + + out = signed_response_django(_neutral_signed()) + assert out.status_code == 200 + assert out["Content-Type"].startswith("application/json") + assert out.content == b'{"ok": true}' + assert out["Cache-Control"] == "public, max-age=60" + + +def test_signed_response_aiohttp_wraps_neutral_payload() -> None: + from agentscore_commerce.discovery import signed_response_aiohttp + + out = signed_response_aiohttp(_neutral_signed()) + assert out.status == 200 + assert out.body == b'{"ok": true}' + assert out.content_type == "application/json" + assert out.headers["Cache-Control"] == "public, max-age=60" + + +def test_signed_response_sanic_wraps_neutral_payload() -> None: + from agentscore_commerce.discovery import signed_response_sanic + + out = signed_response_sanic(_neutral_signed()) + assert out.status == 200 + assert out.body == b'{"ok": true}' + assert out.content_type == "application/json" + + # ───────────────────────────────────────────────────────────────────────────── # build_merchant_index_json # ───────────────────────────────────────────────────────────────────────────── From 0a15eb766201cac158a1d4c73145ceb72f0560b4 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 07:45:08 -0700 Subject: [PATCH 05/24] feat(checkout): mount_ucp_routes_ methods (Tier 2 lift E) Adds mount_ucp_routes_{fastapi,flask,django,aiohttp,sanic} on the Checkout class. Each registers GET /.well-known/ucp + GET /.well-known/jwks.json + an OPTIONS preflight for both, in one call. Saves merchants the ~40-line 3-route registration block every UCP-publishing merchant otherwise hand-rolls. FastAPI handlers patch __annotations__ post-hoc so the Request injection survives `from __future__ import annotations` stringification. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/checkout.py | 264 +++++++++++++++++++++++++++++++- tests/test_seamless_helpers.py | 159 +++++++++++++++++++ 2 files changed, 422 insertions(+), 1 deletion(-) diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index edfdb94..3e6f7d7 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -69,7 +69,7 @@ import inspect import json import uuid -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass, field from typing import Any, Literal, TypeAlias @@ -1062,6 +1062,268 @@ def handle_django(self, request: Any, *, body: dict[str, Any] | None = None) -> result = async_to_sync(self.handle)(checkout_request) return JsonResponse(result.body, status=result.status, headers=self._extra_headers(result.headers)) + # ───────────────────────────────────────────────────────────────────── + # mount_ucp_routes_ — register `/.well-known/ucp` + `/jwks.json` + # + OPTIONS preflights on the app in one call. Saves merchants the ~40-line + # 3-route registration block every UCP-publishing merchant otherwise + # hand-rolls. Equivalent across all five Python framework adapters. + # ───────────────────────────────────────────────────────────────────── + + def _build_ucp_resp( + self, + request_headers: Mapping[str, str], + *, + name: str, + well_known_ucp_url: str, + services: dict[str, Any], + signing_kid: str, + agentscore_gate: Any, + ) -> Any: + from agentscore_commerce.discovery.well_known import build_signed_ucp_response + + return build_signed_ucp_response( + checkout=self, + name=name, + well_known_ucp_url=well_known_ucp_url, + services=services, + request_headers=request_headers, + signing_kid=signing_kid, + agentscore_gate=agentscore_gate, + ) + + def _build_jwks_resp(self, request_headers: Mapping[str, str], *, signing_kid: str) -> Any: + from agentscore_commerce.discovery.well_known import build_signed_jwks_response + + return build_signed_jwks_response(request_headers=request_headers, signing_kid=signing_kid) + + def _build_preflight(self, request_headers: Mapping[str, str]) -> Any: + from agentscore_commerce.discovery.well_known import well_known_preflight_response + + return well_known_preflight_response(request_headers) + + def mount_ucp_routes_fastapi( + self, + app: Any, + *, + name: str, + well_known_ucp_url: str, + services: dict[str, Any], + signing_kid: str = "merchant-default", + agentscore_gate: Any = None, + ucp_path: str = "/.well-known/ucp", + jwks_path: str = "/.well-known/jwks.json", + ) -> None: + """Register signed UCP + JWKS + preflight routes on a FastAPI app.""" + from fastapi import Request + + from agentscore_commerce.discovery.well_known import ( + signed_response_fastapi, + ) + + async def _ucp(request): # type: ignore[no-untyped-def] + return signed_response_fastapi( + self._build_ucp_resp( + dict(request.headers), + name=name, + well_known_ucp_url=well_known_ucp_url, + services=services, + signing_kid=signing_kid, + agentscore_gate=agentscore_gate, + ) + ) + + async def _jwks(request): # type: ignore[no-untyped-def] + return signed_response_fastapi(self._build_jwks_resp(dict(request.headers), signing_kid=signing_kid)) + + async def _preflight(request): # type: ignore[no-untyped-def] + return signed_response_fastapi(self._build_preflight(dict(request.headers))) + + # Patch annotations so FastAPI's signature inspection sees the real + # Request class (PEP 563 / `from __future__ import annotations` would + # otherwise stringify the annotation and break Request injection). + for fn in (_ucp, _jwks, _preflight): + fn.__annotations__ = {"request": Request} + + app.get(ucp_path)(_ucp) + app.get(jwks_path)(_jwks) + app.options(ucp_path)(_preflight) + app.options(jwks_path)(_preflight) + + def mount_ucp_routes_flask( + self, + app: Any, + *, + name: str, + well_known_ucp_url: str, + services: dict[str, Any], + signing_kid: str = "merchant-default", + agentscore_gate: Any = None, + ucp_path: str = "/.well-known/ucp", + jwks_path: str = "/.well-known/jwks.json", + ) -> None: + """Register signed UCP + JWKS + preflight routes on a Flask app.""" + from flask import request as flask_request + + from agentscore_commerce.discovery.well_known import signed_response_flask + + def _ucp() -> Any: + headers = dict(flask_request.headers) + if flask_request.method == "OPTIONS": + return signed_response_flask(self._build_preflight(headers)) + return signed_response_flask( + self._build_ucp_resp( + headers, + name=name, + well_known_ucp_url=well_known_ucp_url, + services=services, + signing_kid=signing_kid, + agentscore_gate=agentscore_gate, + ) + ) + + def _jwks() -> Any: + headers = dict(flask_request.headers) + if flask_request.method == "OPTIONS": + return signed_response_flask(self._build_preflight(headers)) + return signed_response_flask(self._build_jwks_resp(headers, signing_kid=signing_kid)) + + app.add_url_rule( + ucp_path, + "agentscore_ucp", + _ucp, + methods=["GET", "OPTIONS"], + provide_automatic_options=False, + ) + app.add_url_rule( + jwks_path, + "agentscore_jwks", + _jwks, + methods=["GET", "OPTIONS"], + provide_automatic_options=False, + ) + + def mount_ucp_routes_django( + self, + urlpatterns: list[Any], + *, + name: str, + well_known_ucp_url: str, + services: dict[str, Any], + signing_kid: str = "merchant-default", + agentscore_gate: Any = None, + ucp_path: str = ".well-known/ucp", + jwks_path: str = ".well-known/jwks.json", + ) -> None: + """Append signed UCP + JWKS + preflight URL patterns to a Django urlpatterns list. + + Django routes don't take leading slashes; the defaults already omit them. + Each path serves GET + OPTIONS through the same view; the view dispatches + on ``request.method``. + """ + from django.urls import path + + from agentscore_commerce.discovery.well_known import signed_response_django + + def _ucp_view(request: Any) -> Any: + headers = dict(request.headers.items()) + if request.method == "OPTIONS": + return signed_response_django(self._build_preflight(headers)) + return signed_response_django( + self._build_ucp_resp( + headers, + name=name, + well_known_ucp_url=well_known_ucp_url, + services=services, + signing_kid=signing_kid, + agentscore_gate=agentscore_gate, + ) + ) + + def _jwks_view(request: Any) -> Any: + headers = dict(request.headers.items()) + if request.method == "OPTIONS": + return signed_response_django(self._build_preflight(headers)) + return signed_response_django(self._build_jwks_resp(headers, signing_kid=signing_kid)) + + urlpatterns.append(path(ucp_path, _ucp_view)) + urlpatterns.append(path(jwks_path, _jwks_view)) + + def mount_ucp_routes_aiohttp( + self, + app: Any, + *, + name: str, + well_known_ucp_url: str, + services: dict[str, Any], + signing_kid: str = "merchant-default", + agentscore_gate: Any = None, + ucp_path: str = "/.well-known/ucp", + jwks_path: str = "/.well-known/jwks.json", + ) -> None: + """Register signed UCP + JWKS + preflight routes on an aiohttp app.""" + from agentscore_commerce.discovery.well_known import signed_response_aiohttp + + async def _ucp(request: Any) -> Any: + return signed_response_aiohttp( + self._build_ucp_resp( + dict(request.headers), + name=name, + well_known_ucp_url=well_known_ucp_url, + services=services, + signing_kid=signing_kid, + agentscore_gate=agentscore_gate, + ) + ) + + async def _jwks(request: Any) -> Any: + return signed_response_aiohttp(self._build_jwks_resp(dict(request.headers), signing_kid=signing_kid)) + + async def _preflight(request: Any) -> Any: + return signed_response_aiohttp(self._build_preflight(dict(request.headers))) + + app.router.add_get(ucp_path, _ucp) + app.router.add_get(jwks_path, _jwks) + app.router.add_options(ucp_path, _preflight) + app.router.add_options(jwks_path, _preflight) + + def mount_ucp_routes_sanic( + self, + app: Any, + *, + name: str, + well_known_ucp_url: str, + services: dict[str, Any], + signing_kid: str = "merchant-default", + agentscore_gate: Any = None, + ucp_path: str = "/.well-known/ucp", + jwks_path: str = "/.well-known/jwks.json", + ) -> None: + """Register signed UCP + JWKS + preflight routes on a Sanic app.""" + from agentscore_commerce.discovery.well_known import signed_response_sanic + + async def _ucp(request: Any) -> Any: + return signed_response_sanic( + self._build_ucp_resp( + dict(request.headers), + name=name, + well_known_ucp_url=well_known_ucp_url, + services=services, + signing_kid=signing_kid, + agentscore_gate=agentscore_gate, + ) + ) + + async def _jwks(request: Any) -> Any: + return signed_response_sanic(self._build_jwks_resp(dict(request.headers), signing_kid=signing_kid)) + + async def _preflight(request: Any) -> Any: + return signed_response_sanic(self._build_preflight(dict(request.headers))) + + app.add_route(_ucp, ucp_path, methods=["GET"], name="agentscore_ucp") + app.add_route(_jwks, jwks_path, methods=["GET"], name="agentscore_jwks") + app.add_route(_preflight, ucp_path, methods=["OPTIONS"], name="agentscore_ucp_options") + app.add_route(_preflight, jwks_path, methods=["OPTIONS"], name="agentscore_jwks_options") + async def _run_gate(self, ctx: CheckoutContext) -> CheckoutResult | None: """Run the per-request gate. diff --git a/tests/test_seamless_helpers.py b/tests/test_seamless_helpers.py index df9729e..985d4f7 100644 --- a/tests/test_seamless_helpers.py +++ b/tests/test_seamless_helpers.py @@ -1556,6 +1556,165 @@ def test_signed_response_sanic_wraps_neutral_payload() -> None: assert out.content_type == "application/json" +# ───────────────────────────────────────────────────────────────────────────── +# Checkout.mount_ucp_routes_ (Tier 2 lift E) +# ───────────────────────────────────────────────────────────────────────────── + + +def _mounted_checkout_with_key() -> tuple[Any, dict[str, Any]]: + from agentscore_commerce.checkout import Checkout, PricingResult + from agentscore_commerce.identity.ucp_jwks import generate_ucp_signing_key + from agentscore_commerce.payment import TempoRailSpec + + async def _pricing(_ctx: Any) -> PricingResult: + return PricingResult(amount_usd=1.0) + + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xfeedface")}, + url="https://x/purchase", + compute_pricing=_pricing, + ) + key = generate_ucp_signing_key(kid="mount-test") + return checkout, key.private_key.as_dict(private=True) + + +def test_mount_ucp_routes_fastapi_registers_three_routes() -> None: + from fastapi import FastAPI + from fastapi.testclient import TestClient + + checkout, jwk = _mounted_checkout_with_key() + app = FastAPI() + with _env_key(jwk): + checkout.mount_ucp_routes_fastapi( + app, + name="Mount-FastAPI", + well_known_ucp_url="https://x/.well-known/ucp", + services={}, + signing_kid="mount-test", + ) + client = TestClient(app) + ucp = client.get("/.well-known/ucp") + jwks = client.get("/.well-known/jwks.json") + preflight = client.options("/.well-known/ucp") + + assert ucp.status_code == 200 + assert ucp.json()["ucp"]["name"] == "Mount-FastAPI" + assert jwks.status_code == 200 + assert "keys" in jwks.json() + assert preflight.status_code == 204 + assert preflight.headers["access-control-allow-origin"] == "*" + + +def test_mount_ucp_routes_flask_registers_three_routes() -> None: + from flask import Flask + + checkout, jwk = _mounted_checkout_with_key() + app = Flask(__name__) + with _env_key(jwk): + checkout.mount_ucp_routes_flask( + app, + name="Mount-Flask", + well_known_ucp_url="https://x/.well-known/ucp", + services={}, + signing_kid="mount-test", + ) + client = app.test_client() + ucp = client.get("/.well-known/ucp") + jwks = client.get("/.well-known/jwks.json") + preflight = client.options("/.well-known/ucp") + + assert ucp.status_code == 200 + assert ucp.get_json()["ucp"]["name"] == "Mount-Flask" + assert jwks.status_code == 200 + assert preflight.status_code == 204 + + +def test_mount_ucp_routes_django_appends_urlpatterns() -> None: + import django + from django.conf import settings + from django.test import RequestFactory + + if not settings.configured: + settings.configure(DEBUG=False, ALLOWED_HOSTS=["*"], DEFAULT_CHARSET="utf-8", ROOT_URLCONF=__name__) + django.setup() + + checkout, jwk = _mounted_checkout_with_key() + patterns: list[Any] = [] + with _env_key(jwk): + checkout.mount_ucp_routes_django( + patterns, + name="Mount-Django", + well_known_ucp_url="https://x/.well-known/ucp", + services={}, + signing_kid="mount-test", + ) + rf = RequestFactory() + ucp_view = patterns[0].callback + jwks_view = patterns[1].callback + ucp_resp = ucp_view(rf.get("/.well-known/ucp")) + jwks_resp = jwks_view(rf.get("/.well-known/jwks.json")) + preflight_resp = ucp_view(rf.options("/.well-known/ucp")) + + assert ucp_resp.status_code == 200 + assert json.loads(ucp_resp.content)["ucp"]["name"] == "Mount-Django" + assert jwks_resp.status_code == 200 + assert preflight_resp.status_code == 204 + + +def test_mount_ucp_routes_aiohttp_registers_three_routes() -> None: + import asyncio + + from aiohttp import web + from aiohttp.test_utils import TestClient, TestServer + + checkout, jwk = _mounted_checkout_with_key() + + async def _run() -> None: + app = web.Application() + checkout.mount_ucp_routes_aiohttp( + app, + name="Mount-Aiohttp", + well_known_ucp_url="https://x/.well-known/ucp", + services={}, + signing_kid="mount-test", + ) + async with TestClient(TestServer(app)) as client: + ucp_resp = await client.get("/.well-known/ucp") + jwks_resp = await client.get("/.well-known/jwks.json") + preflight_resp = await client.options("/.well-known/ucp") + assert ucp_resp.status == 200 + ucp_body = await ucp_resp.json() + assert ucp_body["ucp"]["name"] == "Mount-Aiohttp" + assert jwks_resp.status == 200 + assert preflight_resp.status == 204 + + with _env_key(jwk): + asyncio.run(_run()) + + +def test_mount_ucp_routes_sanic_registers_three_routes() -> None: + from sanic import Sanic + + Sanic._app_registry.clear() + checkout, jwk = _mounted_checkout_with_key() + app: Any = Sanic("agentscore-mount-test") + with _env_key(jwk): + checkout.mount_ucp_routes_sanic( + app, + name="Mount-Sanic", + well_known_ucp_url="https://x/.well-known/ucp", + services={}, + signing_kid="mount-test", + ) + _, ucp_resp = app.test_client.get("/.well-known/ucp") + _, jwks_resp = app.test_client.get("/.well-known/jwks.json") + _, preflight_resp = app.test_client.options("/.well-known/ucp") + assert ucp_resp.status == 200 + assert ucp_resp.json["ucp"]["name"] == "Mount-Sanic" + assert jwks_resp.status == 200 + assert preflight_resp.status == 204 + + # ───────────────────────────────────────────────────────────────────────────── # build_merchant_index_json # ───────────────────────────────────────────────────────────────────────────── From 4ead6e47dba1e71c08525b1e867452d2b3c2a854 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 07:50:37 -0700 Subject: [PATCH 06/24] docs(examples): wire 4 examples to Tier 1+2 helpers (B, C, D, E) - api_provider.py: Checkout(discovery_probe=...) replaces inline probe routing - per_product_policy_merchant.py: validate_shipping_against_policy replaces shipping_country_allowed + shipping_state_allowed pair - multi_rail_merchant.py: pricing_result() replaces build_pricing_block + body_extras dance - signed_ucp_merchant.py: Checkout.mount_ucp_routes_fastapi(...) replaces 3-route hand-mount Net: ~80 lines removed across examples; the underlying helpers carry the boilerplate now. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/api_provider.py | 45 +++++---------- examples/multi_rail_merchant.py | 13 ++--- examples/per_product_policy_merchant.py | 21 +++---- examples/signed_ucp_merchant.py | 77 ++++++++----------------- 4 files changed, 52 insertions(+), 104 deletions(-) diff --git a/examples/api_provider.py b/examples/api_provider.py index 61c6430..fefbe3d 100644 --- a/examples/api_provider.py +++ b/examples/api_provider.py @@ -35,20 +35,14 @@ Run: uvicorn examples.api_provider:app --port 3000 """ -import json import os from typing import Any from fastapi import FastAPI, Request from fastapi.responses import JSONResponse -from agentscore_commerce import Checkout, PricingResult, SettleOutcome -from agentscore_commerce.discovery import ( - NoindexNonDiscoveryMiddleware, - X402SampleProbe, - build_discovery_probe_response, - is_discovery_probe_request, -) +from agentscore_commerce import Checkout, DiscoveryProbeConfig, PricingResult, SettleOutcome +from agentscore_commerce.discovery import NoindexNonDiscoveryMiddleware, X402SampleProbe from agentscore_commerce.payment import ( SolanaMppRailSpec, TempoRailSpec, @@ -107,30 +101,23 @@ async def _on_settled(ctx: Any, _outcome: SettleOutcome) -> dict[str, Any]: cdp_api_key_id=os.environ.get("CDP_API_KEY_ID"), cdp_api_key_secret=os.environ.get("CDP_API_KEY_SECRET"), mppx_secret_key=os.environ.get("MPP_SECRET_KEY"), + # Auto-route empty-body POSTs without a payment header to a sample 402 so + # crawlers (`awal x402 details`, x402-proxy, ...) can find this surface + # without committing to a real charge. The probe advertises SAMPLE accepts; + # real rails fire only when the agent retries with a credential. + discovery_probe=DiscoveryProbeConfig( + realm=REALM, + sample_rail=_TEMPO_RAIL_NAME, + sample_amount_usd=PRICE_USDC, + sample_recipient=os.environ["TEMPO_RECIPIENT"], + x402_sample=X402SampleProbe( + networks=[X402_BASE_NETWORK, SOLANA_NETWORK_CAIP2], + resource_url=f"https://{REALM}/search", + ), + ), ) @app.post("/search") async def search(request: Request) -> JSONResponse: - body_bytes = await request.body() - body_text = body_bytes.decode() if body_bytes else "" - auth = request.headers.get("authorization") - - # Discovery probe: empty-body POST without any payment header. Return sample - # 402 so crawlers (`awal x402 details`, x402-proxy, ...) can find this surface - # without committing to a real charge. Handle inline because the probe - # advertises SAMPLE accepts (not the merchant's real settle rails). - if await is_discovery_probe_request(request.method, auth, body_text): - probe = build_discovery_probe_response( - realm=REALM, - sample_rail=_TEMPO_RAIL_NAME, - sample_amount_usd=PRICE_USDC, - sample_recipient=os.environ["TEMPO_RECIPIENT"], - x402_sample=X402SampleProbe( - networks=[X402_BASE_NETWORK, SOLANA_NETWORK_CAIP2], - resource_url=f"https://{REALM}/search", - ), - ) - return JSONResponse(json.loads(probe.body), status_code=probe.status, headers=probe.headers) - return await checkout.handle_fastapi(request) diff --git a/examples/multi_rail_merchant.py b/examples/multi_rail_merchant.py index cc35cb4..a515c16 100644 --- a/examples/multi_rail_merchant.py +++ b/examples/multi_rail_merchant.py @@ -50,8 +50,8 @@ CheckoutValidationError, PricingResult, SettleOutcome, + pricing_result, ) -from agentscore_commerce.challenge import build_pricing_block from agentscore_commerce.payment import ( SolanaMppRailSpec, StripeRailSpec, @@ -95,17 +95,12 @@ async def _validate_purchase(ctx: Any) -> dict[str, Any]: async def _compute_pricing(ctx: Any) -> PricingResult: - subtotal_cents = 25000 # $250.00; vendor pricing logic goes here. - tax_cents = 2000 - total_cents = subtotal_cents + tax_cents - pricing = build_pricing_block( - subtotal_cents=subtotal_cents, - tax_cents=tax_cents, + return pricing_result( + subtotal_cents=25000, # $250.00; vendor pricing logic goes here. + tax_cents=2000, tax_rate=0.08, tax_state=ctx.state.get("shipping_state", "CA"), - currency="USD", ) - return PricingResult(amount_usd=total_cents / 100, body_extras={"pricing": pricing}) async def _mint_recipients(ctx: Any) -> dict[str, str]: diff --git a/examples/per_product_policy_merchant.py b/examples/per_product_policy_merchant.py index b9c044c..7b9de60 100644 --- a/examples/per_product_policy_merchant.py +++ b/examples/per_product_policy_merchant.py @@ -42,10 +42,7 @@ PricingResult, SettleOutcome, ) -from agentscore_commerce.identity.policy import ( - shipping_country_allowed, - shipping_state_allowed, -) +from agentscore_commerce.identity.policy import validate_shipping_against_policy from agentscore_commerce.payment import TempoRailSpec API_KEY = os.environ.get("AGENTSCORE_API_KEY", "ask_test_dummy") @@ -90,16 +87,12 @@ async def _validate_purchase(ctx: Any) -> dict[str, Any]: raise CheckoutValidationError(code="product_not_found", message=f"No product with slug {slug!r}.") policy = product["policy"] - if not shipping_country_allowed(shipping.get("country", ""), policy): - raise CheckoutValidationError( - code="unsupported_jurisdiction", - message=f"Cannot ship to {shipping.get('country')}.", - ) - if not shipping_state_allowed(shipping.get("state", ""), shipping.get("country", ""), policy): - raise CheckoutValidationError( - code="unsupported_jurisdiction", - message=f"Cannot ship to {shipping.get('state')}.", - ) + validate_shipping_against_policy( + country=shipping.get("country", ""), + state=shipping.get("state", ""), + policy=policy, + product_name=product["name"], + ) return {"product": product, "policy": policy} diff --git a/examples/signed_ucp_merchant.py b/examples/signed_ucp_merchant.py index 07d6a83..3683ed6 100644 --- a/examples/signed_ucp_merchant.py +++ b/examples/signed_ucp_merchant.py @@ -6,10 +6,11 @@ production UCP merchants commonly ship unsigned, and vanilla UCP-aware agents read the canonical body and ignore the ``signature`` field. -The 2.0 SDK ships `build_signed_ucp_response` + `build_signed_jwks_response` -which fold loading + signing + Cache-Control + CORS into one call. Pass a -`Checkout` instance and the helpers compose the `payment_handlers` block -from the configured rails automatically. +The 2.0 SDK ships :meth:`Checkout.mount_ucp_routes_fastapi` (and one for each +framework adapter) which folds loading + signing + Cache-Control + CORS + the +3-route registration block (GET ucp + GET jwks + OPTIONS preflight) into one +call. Pass the merchant's :class:`Checkout` and the helpers compose the +``payment_handlers`` block from the configured rails automatically. Run:: @@ -25,7 +26,7 @@ new profiles with the new key, then dropping the old JWK after your verifier cache TTL expires. -Call `bootstrap_ucp_signing_key()` in your lifespan handler so a malformed +Call :func:`bootstrap_ucp_signing_key` in your lifespan handler so a malformed ``UCP_SIGNING_KEY_JWK_PRIVATE`` env value fails the deploy fast instead of surfacing on the first ``/.well-known/ucp`` hit. """ @@ -35,17 +36,11 @@ from contextlib import asynccontextmanager from typing import Any -from fastapi import FastAPI, Request, Response +from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from agentscore_commerce import AgentScoreGatePolicy, Checkout, PricingResult -from agentscore_commerce.discovery import ( - bootstrap_ucp_signing_key, - build_signed_jwks_response, - build_signed_ucp_response, - default_a2a_services, - well_known_preflight_response, -) +from agentscore_commerce.discovery import bootstrap_ucp_signing_key, default_a2a_services from agentscore_commerce.payment import TempoRailSpec SIGNING_KID = "merchant-2026-05" @@ -64,47 +59,24 @@ async def _compute_pricing(_ctx: Any) -> PricingResult: @asynccontextmanager async def lifespan(_app: FastAPI): - # Eager-load the signing key so a malformed env JWK fails the deploy fast. bootstrap_ucp_signing_key(default_kid=SIGNING_KID) yield app = FastAPI(lifespan=lifespan) - -@app.get("/.well-known/ucp") -async def well_known_ucp(request: Request) -> Response: - resp = build_signed_ucp_response( - checkout=checkout, - name="My Agent Service", - well_known_ucp_url="https://agents.example.com/.well-known/ucp", - services=default_a2a_services(agent_card_url="https://agents.example.com/.well-known/agent-card.json"), - request_headers=dict(request.headers), - signing_kid=SIGNING_KID, - # Optional: declare merchant gate policy as an `sh.agentscore.identity` - # capability binding inside the public profile. Static policy - # declaration only; per-operator identity attestation flows through the - # AP2 risk-signal endpoint. - agentscore_gate=AgentScoreGatePolicy( - require_kyc=True, - min_age=21, - allowed_jurisdictions=["US"], - ), - ) - return Response(content=resp.content, status_code=resp.status, media_type=resp.media_type, headers=resp.headers) - - -@app.get("/.well-known/jwks.json") -async def well_known_jwks(request: Request) -> Response: - resp = build_signed_jwks_response(request_headers=dict(request.headers), signing_kid=SIGNING_KID) - return Response(content=resp.content, status_code=resp.status, media_type=resp.media_type, headers=resp.headers) - - -@app.options("/.well-known/ucp") -@app.options("/.well-known/jwks.json") -async def well_known_preflight(request: Request) -> Response: - preflight = well_known_preflight_response(dict(request.headers)) - return Response(status_code=preflight.status, headers=preflight.headers) +checkout.mount_ucp_routes_fastapi( + app, + name="My Agent Service", + well_known_ucp_url="https://agents.example.com/.well-known/ucp", + services=default_a2a_services(agent_card_url="https://agents.example.com/.well-known/agent-card.json"), + signing_kid=SIGNING_KID, + agentscore_gate=AgentScoreGatePolicy( + require_kyc=True, + min_age=21, + allowed_jurisdictions=["US"], + ), +) @app.get("/_selftest/ucp") @@ -112,12 +84,13 @@ async def selftest(request: Request) -> JSONResponse: """Local round-trip: sign+serve+fetch+verify, return UCPVerificationError code on failure.""" import json + from starlette.testclient import TestClient + from agentscore_commerce.identity import UCPVerificationError, verify_ucp_profile - profile_resp = await well_known_ucp(request) - jwks_resp = await well_known_jwks(request) - profile = json.loads(bytes(profile_resp.body)) - jwks = json.loads(bytes(jwks_resp.body)) + client = TestClient(app) + profile = client.get("/.well-known/ucp").json() + jwks = json.loads(client.get("/.well-known/jwks.json").content) try: verify_ucp_profile(profile, jwks) return JSONResponse({"ok": True, "kid": profile["signing_keys"][0]["kid"]}) From 2e08b8d97ec3c567d245248c0f47c2b08578aafe Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 08:08:31 -0700 Subject: [PATCH 07/24] chore: SDK cleanup wave 1: kill handle_starlette alias + de-goods-ify universal copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kill handle_starlette = handle_fastapi alias (FastAPI inherits from Starlette; one adapter already covers both). - agent_instructions warnings + llms_txt: "order will not complete" → "request will not complete" (universal across goods + API merchants). - build_success_next_steps default: "Order complete." → "Payment complete." - build_contact_support_next_steps default: "with your order details" → "with the details of your request." No behavioural change. All helpers continue to accept caller overrides; only defaults changed so API-merchant consumers stop having to override every call. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/challenge/agent_instructions.py | 4 ++-- agentscore_commerce/checkout.py | 3 --- agentscore_commerce/discovery/agentscore_content.py | 2 +- agentscore_commerce/discovery/llms_txt.py | 2 +- agentscore_commerce/identity/_denial.py | 5 ++++- tests/test_seamless_helpers.py | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/agentscore_commerce/challenge/agent_instructions.py b/agentscore_commerce/challenge/agent_instructions.py index ab6b43a..b6372b2 100644 --- a/agentscore_commerce/challenge/agent_instructions.py +++ b/agentscore_commerce/challenge/agent_instructions.py @@ -5,12 +5,12 @@ _TEMPO_WARNING = ( "Do NOT use `tempo wallet transfer` to pay to the address above. That moves USDC on-chain but does not " - "notify this server, so the order will not complete. Use `tempo request` instead; it performs the full MPP " + "notify this server, so the request will not complete. Use `tempo request` instead; it performs the full MPP " "handshake (signs, submits Authorization: Payment, waits for server confirmation)." ) _X402_WARNING = ( "Do NOT send USDC manually to the x402 deposit addresses (e.g. via a bare wallet `transfer`). Use " - "`agentscore-pay pay` so the X-Payment credential is signed and submitted; otherwise the order will not " + "`agentscore-pay pay` so the X-Payment credential is signed and submitted; otherwise the request will not " "complete even though the deposit lands." ) _TEMPO_TOOL = "`tempo request` for Tempo USDC (installs via `tempo add request`)" diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index 3e6f7d7..f149868 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -941,9 +941,6 @@ async def handle_fastapi(self, request: Any, *, body: dict[str, Any] | None = No 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``. diff --git a/agentscore_commerce/discovery/agentscore_content.py b/agentscore_commerce/discovery/agentscore_content.py index 4e6cfd5..6b1d62b 100644 --- a/agentscore_commerce/discovery/agentscore_content.py +++ b/agentscore_commerce/discovery/agentscore_content.py @@ -270,7 +270,7 @@ def build_success_next_steps( out: dict[str, str] = { "action": "done", "user_message": user_message - or ("Order complete. Your AgentScore Passport is now active across every AgentScore-gated merchant."), + or ("Payment complete. Your AgentScore Passport is now active across every AgentScore-gated merchant."), } if order_status_url: out["order_status_url"] = order_status_url diff --git a/agentscore_commerce/discovery/llms_txt.py b/agentscore_commerce/discovery/llms_txt.py index b7011db..72040fa 100644 --- a/agentscore_commerce/discovery/llms_txt.py +++ b/agentscore_commerce/discovery/llms_txt.py @@ -248,7 +248,7 @@ def _llms_txt_payment_section_verbose( lines.append( "IMPORTANT: Do NOT use `tempo wallet transfer` or send USDC manually to the x402 deposit addresses; " - "those bypass the payment handshake and the order will not complete." + "those bypass the payment handshake and the request will not complete." ) if has_base or has_solana: lines.append( diff --git a/agentscore_commerce/identity/_denial.py b/agentscore_commerce/identity/_denial.py index 1556a36..6ad391b 100644 --- a/agentscore_commerce/identity/_denial.py +++ b/agentscore_commerce/identity/_denial.py @@ -138,7 +138,10 @@ def build_contact_support_next_steps(support_email: str, message: str | None = N "action": "contact_support", "support_email": support_email, "user_message": message - or f"If you believe this denial is in error, contact support at {support_email} with your order details.", + or ( + f"If you believe this denial is in error, contact support at " + f"{support_email} with the details of your request." + ), } diff --git a/tests/test_seamless_helpers.py b/tests/test_seamless_helpers.py index 985d4f7..4f9353e 100644 --- a/tests/test_seamless_helpers.py +++ b/tests/test_seamless_helpers.py @@ -367,7 +367,7 @@ def test_build_success_next_steps_omits_eta_when_missing() -> None: "action": "done", "order_status_url": "https://x/orders/1", "user_message": ( - "Order complete. Your AgentScore Passport is now active across every AgentScore-gated merchant." + "Payment complete. Your AgentScore Passport is now active across every AgentScore-gated merchant." ), } From a75509a244b240481cf93cb49149d8d5ca2f12e3 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 08:29:07 -0700 Subject: [PATCH 08/24] chore: SDK cleanup wave 2: redesigns + identity_metadata wire-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five-change wave focused on universalizing surface for both goods + API merchants and on auto-attaching body-piece helpers from the orchestrator. 1. Rename order_receipt.py → receipt.py; OrderReceipt → Receipt; OrderProductInfo → ProductInfo; OrderNextSteps → ReceiptNextSteps. Goods-only slots (shipping, tracking_number, fulfillment_status, gift_note) stay optional; docstrings explicitly tag goods-only vs universal fields. Zero consumers used the old names so no consumer migration needed. 2. standard_endpoint_descriptions takes a kind: Literal["goods", "api"] parameter; ships separate canonical bundles. Goods bundle stays current (/catalog, /purchase, /orders/{id}); API bundle is / + /usage. 3. build_redemption_skill_md prose rewritten to be delivery-neutral (printed mailers, emailed codes, in-app, API trial credits all covered). Adds endpoint_path, delivery_intro, body_shape, body_rules, extra_recovery_rows params so API merchants can pass non-goods shapes without rewriting the whole template. 4. Wire build_identity_metadata into Checkout._emit_402 via _resolve_identity_metadata: when X-Wallet-Address header is present, the 402 body now advertises identity_mode/required_signer/signer_constraint (and linked_wallets when the gate populated request.assess). Agents self-correct at discovery instead of at the 403 retry. 5. (No-op) Keep createMppxStripe rewrap as-is per design discussion. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/challenge/__init__.py | 16 +- .../challenge/order_receipt.py | 86 ----------- agentscore_commerce/challenge/receipt.py | 113 ++++++++++++++ agentscore_commerce/checkout.py | 32 ++++ .../discovery/agentscore_content.py | 25 +++- .../discovery/redemption_md.py | 141 ++++++++++++------ tests/test_checkout.py | 43 ++++++ tests/test_seamless_helpers.py | 8 + 8 files changed, 318 insertions(+), 146 deletions(-) delete mode 100644 agentscore_commerce/challenge/order_receipt.py create mode 100644 agentscore_commerce/challenge/receipt.py diff --git a/agentscore_commerce/challenge/__init__.py b/agentscore_commerce/challenge/__init__.py index 71bc7d7..1d6b0b3 100644 --- a/agentscore_commerce/challenge/__init__.py +++ b/agentscore_commerce/challenge/__init__.py @@ -10,23 +10,23 @@ 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.identity import IdentityMode, SignerMatchResult, build_identity_metadata -from agentscore_commerce.challenge.order_receipt import ( - OrderNextSteps, - OrderProductInfo, - OrderReceipt, +from agentscore_commerce.challenge.pricing import PricingBlock, build_pricing_block +from agentscore_commerce.challenge.receipt import ( + ProductInfo, + Receipt, + ReceiptNextSteps, ShippingAddress, ) -from agentscore_commerce.challenge.pricing import PricingBlock, build_pricing_block from agentscore_commerce.challenge.respond_402 import Respond402Result, respond_402 from agentscore_commerce.challenge.validation_error import build_validation_error __all__ = [ "AgentMemoryHint", "IdentityMode", - "OrderNextSteps", - "OrderProductInfo", - "OrderReceipt", "PricingBlock", + "ProductInfo", + "Receipt", + "ReceiptNextSteps", "Respond402Result", "ShippingAddress", "SignerMatchResult", diff --git a/agentscore_commerce/challenge/order_receipt.py b/agentscore_commerce/challenge/order_receipt.py deleted file mode 100644 index 3f5b0e0..0000000 --- a/agentscore_commerce/challenge/order_receipt.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Canonical order-receipt shape returned to agents on the 200 after settlement. - -Merchants own their order schema, but converging on this shape across AgentScore-gated -merchants means agents can render and post-process orders consistently. Lift this type, -fill the fields you care about, and ignore (or extend via ``extras``) what you don't. - -All money fields are dollar-strings. Use :func:`build_pricing_block` from -:mod:`agentscore_commerce.challenge` to compose the pricing fields from cents. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from agentscore_commerce.challenge.pricing import PricingBlock - - -@dataclass -class ShippingAddress: - """Physical-goods shipping address.""" - - name: str | None = None - address_1: str | None = None - address_2: str | None = None - city: str | None = None - state: str | None = None - zip: str | None = None - country: str | None = None - - -@dataclass -class OrderProductInfo: - """Product info echoed on the receipt — confirms what was bought.""" - - id: str | None = None - name: str | None = None - slug: str | None = None - - -@dataclass -class OrderNextSteps: - """Next-steps block guiding the agent on what to do post-purchase.""" - - user_message: str | None = None - order_status_url: str | None = None - fulfillment_eta: str | None = None - extras: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class OrderReceipt: - """Order receipt returned on 200 after a successful settlement.""" - - id: str - """Stable order id — UUID, slug, or platform-native (Commerce7 order id, etc.).""" - - created_at: str - """ISO-8601 timestamp of order creation.""" - - quantity: int | None = None - product: OrderProductInfo | None = None - pricing: PricingBlock | None = None - email: str | None = None - - payment_status: str | None = None - """Typically ``"completed"``, ``"pending"``, ``"failed"``.""" - - fulfillment_status: str | None = None - """Typically ``"pending"``, ``"shipped"``, ``"delivered"``, ``"cancelled"``.""" - - tracking_number: str | None = None - """Carrier tracking number when fulfillment_status >= shipped.""" - - shipping: ShippingAddress | None = None - """Physical-goods shipping address. Omit for digital goods.""" - - gift_note: str | None = None - extras: dict[str, Any] = field(default_factory=dict) - """Vendor-specific extras merged at the top level (loyalty points, warranty, etc.).""" - - next_steps: OrderNextSteps | None = None - - -__all__ = ["OrderNextSteps", "OrderProductInfo", "OrderReceipt", "ShippingAddress"] diff --git a/agentscore_commerce/challenge/receipt.py b/agentscore_commerce/challenge/receipt.py new file mode 100644 index 0000000..3eee09f --- /dev/null +++ b/agentscore_commerce/challenge/receipt.py @@ -0,0 +1,113 @@ +"""Canonical receipt shape returned to agents on the 200 after settlement. + +Universal across vendor types: goods merchants populate the shipping + +fulfillment slots, API merchants populate only the core fields (id, created_at, +pricing, payment_status, next_steps). All goods-only fields are optional. + +Merchants own their order schema, but converging on this shape across +AgentScore-gated merchants means agents can render and post-process receipts +consistently regardless of whether the seller ships product or returns API +output. Lift this type, fill the fields you care about, and ignore (or extend +via ``extras``) what you don't. + +All money fields are dollar-strings. Use :func:`build_pricing_block` from +:mod:`agentscore_commerce.challenge` to compose the pricing fields from cents. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from agentscore_commerce.challenge.pricing import PricingBlock + + +@dataclass +class ShippingAddress: + """Physical-goods shipping address. Omit for digital goods or API receipts.""" + + name: str | None = None + address_1: str | None = None + address_2: str | None = None + city: str | None = None + state: str | None = None + zip: str | None = None + country: str | None = None + + +@dataclass +class ProductInfo: + """Product info echoed on the receipt. + + Goods merchants populate; API merchants typically omit (per-call billing + has no product concept). + """ + + id: str | None = None + name: str | None = None + slug: str | None = None + + +@dataclass +class ReceiptNextSteps: + """Next-steps block guiding the agent post-settlement. + + ``order_status_url`` works for both: goods merchants point at their order + detail route, API merchants can point at a usage / billing dashboard. + ``fulfillment_eta`` is goods-only; omit for API or digital receipts. + """ + + user_message: str | None = None + order_status_url: str | None = None + fulfillment_eta: str | None = None + extras: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class Receipt: + """Receipt returned on 200 after a successful settlement. + + Universal: goods merchants fill the shipping + fulfillment + product slots, + API merchants populate only id + created_at + pricing + payment_status + + next_steps. All goods-only fields below are optional. + """ + + id: str + """Stable receipt id; order UUID for goods, request id for API merchants.""" + + created_at: str + """ISO-8601 timestamp of settlement.""" + + quantity: int | None = None + """Goods: units purchased. API: usage count (calls, tokens, requests).""" + + product: ProductInfo | None = None + """Goods-shaped. Omit for API merchants.""" + + pricing: PricingBlock | None = None + email: str | None = None + + payment_status: str | None = None + """Typically ``"completed"``, ``"pending"``, ``"failed"``.""" + + fulfillment_status: str | None = None + """Goods-only. Typically ``"pending"``, ``"shipped"``, ``"delivered"``, ``"cancelled"``.""" + + tracking_number: str | None = None + """Goods-only. Carrier tracking number when fulfillment_status >= shipped.""" + + shipping: ShippingAddress | None = None + """Goods-only. Omit for digital goods, services, or API receipts.""" + + gift_note: str | None = None + """Goods-only. Omit for API receipts.""" + + extras: dict[str, Any] = field(default_factory=dict) + """Vendor-specific extras merged at the top level (loyalty points, + warranty, per-call usage breakdown, etc.).""" + + next_steps: ReceiptNextSteps | None = None + + +__all__ = ["ProductInfo", "Receipt", "ReceiptNextSteps", "ShippingAddress"] diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index f149868..e43a3b4 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -530,6 +530,32 @@ async def _maybe_await(value: Any) -> Any: return value +def _resolve_identity_metadata(ctx: CheckoutContext) -> dict[str, Any] | None: + """Compose the identity_metadata block from request + assess state. + + Wallet-mode merchants get ``required_signer`` + ``linked_wallets`` + + ``signer_constraint`` pre-advertised on the 402, so agents self-correct at + discovery instead of at the 403 retry. Returns ``None`` when the request + shows no wallet intent (operator-token only); the 402 then omits the block + entirely. + """ + from agentscore_commerce.challenge.identity import build_identity_metadata + + lower = {k.lower(): v for k, v in ctx.request.headers.items()} + wallet = lower.get("x-wallet-address") + if not wallet: + return None + linked_wallets: list[str] | None = None + assess = ctx.request.assess + if isinstance(assess, dict): + identity = assess.get("identity") + if isinstance(identity, dict): + lw = identity.get("linked_wallets") + if isinstance(lw, list) and all(isinstance(x, str) for x in lw): + linked_wallets = lw + return build_identity_metadata(mode="wallet", wallet=wallet, linked_wallets=linked_wallets) + + class Checkout: """High-level agent-commerce orchestrator. @@ -1770,9 +1796,15 @@ async def _emit_402( # keep other rails in the body. Merchant logs internally. x402_accepts = [] + # Pre-advertise wallet-mode signer constraint when the request shows + # wallet intent. Saves agents a round trip: they learn required_signer + # + linked_wallets at discovery instead of at the 403 on retry. + identity_metadata = _resolve_identity_metadata(ctx) + body = build_402_body( accepted_methods=accepted, agent_instructions=build_agent_instructions(how_to_pay=how_to_pay), + identity_metadata=identity_metadata, pricing=pricing_block, amount_usd=f"{ctx.pricing.amount_usd:.2f}", retry_body=ctx.request.body, diff --git a/agentscore_commerce/discovery/agentscore_content.py b/agentscore_commerce/discovery/agentscore_content.py index 6b1d62b..7f6f467 100644 --- a/agentscore_commerce/discovery/agentscore_content.py +++ b/agentscore_commerce/discovery/agentscore_content.py @@ -219,20 +219,35 @@ def build_merchant_index_json( return body -def standard_endpoint_descriptions(*, include_order_status_route: bool = False) -> dict[str, str]: +def standard_endpoint_descriptions( + *, + kind: Literal["goods", "api"] = "goods", + 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 + 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}``. + Pass ``kind="api"`` for per-call API providers; the bundle drops catalog + + orders routes and surfaces ``POST /`` + ``GET /usage`` instead. + + ``include_order_status_route=True`` (goods only) adds the lightweight + ``/orders/{id}/status`` PII-free variant alongside the full ``/orders/{id}``. """ + if kind == "api": + return { + "POST /": ( + "Per-call paid endpoint. Returns 402 on the discovery leg with " + "payment rails; 400 on body rejection; 403 + recovery payload " + "when identity is required; 200 with the call result on success." + ), + "GET /usage": "Per-credential usage / billing summary. Identity-scoped.", + } out: dict[str, str] = { "GET /catalog": "List purchasable products.", "GET /catalog/{slug}": "Single product detail.", diff --git a/agentscore_commerce/discovery/redemption_md.py b/agentscore_commerce/discovery/redemption_md.py index 1be0f56..e9ada1e 100644 --- a/agentscore_commerce/discovery/redemption_md.py +++ b/agentscore_commerce/discovery/redemption_md.py @@ -1,8 +1,13 @@ -"""Standard ``/redemption.md`` template for merchants offering printed-mailer redemption codes. +"""Standard ``/redemption.md`` template for merchants offering 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. +Renders the canonical cold-start bootstrap + TL;DR + recovery table + body / +code rules for any merchant that accepts single-use codes against a paid +endpoint. The pattern is delivery-neutral: codes can be printed on a mailer, +emailed, surfaced in-app, or issued as API trial credits. + +Goods merchants get the default body-shape (product_slug + shipping + email). +API merchants or digital-credit issuers pass ``body_shape`` to override the +JSON example, and ``extra_recovery_rows`` to add merchant-specific error rows. Mirrors the prose every AgentScore merchant otherwise hand-writes so agents encounter the same shape of redemption flow at any merchant. @@ -12,44 +17,95 @@ from __future__ import annotations +_DEFAULT_BODY_SHAPE = """{ + "product_slug": "", + "redemption_code": "", + "email": "user@example.com", + "shipping": { "name": "...", "address_1": "...", "city": "...", "state": "CA", "zip": "94573" } + }""" + +_DEFAULT_BODY_RULES = """## 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; the server returns 422 on malformed input.""" + def build_redemption_skill_md( *, merchant_name: str, app_url: str, + endpoint_path: str = "/purchase", sku_intro: str | None = None, + delivery_intro: str | None = None, + body_shape: str | None = None, + body_rules: str | None = None, + extra_recovery_rows: str | None = None, peer_merchant_pointer: str | None = None, ) -> str: """Render the canonical ``redemption.md`` for an AgentScore merchant. + ``endpoint_path`` is the merchant's redemption endpoint relative to + ``app_url``. Defaults to ``"/purchase"`` for goods merchants; API merchants + typically pass ``"/"`` (the per-call paid route that accepts a + ``redemption_code`` field in the body). + + ``delivery_intro`` overrides the cold-start paragraph describing how the + code was distributed. Default copy covers printed mailers, emails, and any + other out-of-band delivery channel. API merchants distributing trial credits + might override with vendor-specific language. + + ``body_shape`` is the JSON example shown in the TL;DR. Defaults to the + goods-merchant shape (product_slug + redemption_code + email + shipping). + API merchants pass their endpoint's body shape (which still includes + ``redemption_code``). + + ``body_rules`` overrides the body-rules section. Default covers + goods-shipping rules; API merchants typically pass either ``""`` (drop the + section) or their own constraints. + + ``extra_recovery_rows`` is appended verbatim to the recovery table after + the universal rows. Use it for merchant-specific error codes (e.g. + ``unsupported_jurisdiction`` rows, per-tier code rules). + ``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. + merchant. 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. + at the bottom. 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`." + "The code redeems a product or paid call at this merchant which you'll find in " + "the merchant's catalog or per-endpoint documentation with `purchase_mode = redemption_only`." ) + delivery_text = delivery_intro or ( + f"You're reading this because the user you're working for received a single-use " + f"redemption code from {merchant_name} (printed on a mailer, emailed, surfaced in-app, " + "or distributed out-of-band). This page tells you, the agent, exactly how to turn " + "that code into a settled call." + ) + rendered_body_shape = body_shape or _DEFAULT_BODY_SHAPE + rendered_body_rules = body_rules if body_rules is not None else _DEFAULT_BODY_RULES 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" + "This page is the redemption flow for single-use codes. " + f"If you're looking to buy or call without a code, see: {peer_merchant_pointer}\n" ) - return f"""# Redeeming an AgentScore mailer code at {merchant_name} + body_rules_section = f"\n{rendered_body_rules}\n" if rendered_body_rules else "" + extra_rows = f"\n{extra_recovery_rows.rstrip()}" if extra_recovery_rows else "" + + return f"""# Redeeming an AgentScore 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. +{delivery_text} -{sku_text} The 402 challenge on /purchase tells you the actual settle amount +{sku_text} The 402 challenge on {endpoint_path} tells you the actual settle amount after the code is applied; discounts can range from a partial amount off list down to free. @@ -72,17 +128,16 @@ def build_redemption_skill_md( ## 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: +1. Ask the user for their redemption code, plus any merchant-specific fields the + body requires (email, shipping address for goods merchants, identifiers for + API merchants, etc.). +2. Discover the redemption-eligible target. Goods merchants: `GET {app_url}/catalog` + and find the product whose `purchase_mode` is `redemption_only`. API merchants: + read the per-endpoint docs for the route that accepts `redemption_code`. + Read any `purchase_note` for product-specific rules. +3. `POST {app_url}{endpoint_path}` with body: ```json - {{ - "product_slug": "", - "redemption_code": "", - "email": "user@example.com", - "shipping": {{ "name": "...", "address_1": "...", "city": "...", "state": "CA", "zip": "94573" }} - }} + {rendered_body_shape} ``` 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 @@ -90,23 +145,16 @@ def build_redemption_skill_md( 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 '' + Settle with `agentscore-pay pay POST {app_url}{endpoint_path} --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. - +6. **200**; the call settled. Response carries a receipt `id` (order id for goods, + request id for API), `next_steps.order_status_url` (or usage dashboard 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_section} ## Code rules - Codes are case-insensitive (server uppercases on receipt), single-use, and @@ -122,13 +170,12 @@ def build_redemption_skill_md( | 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 | `product_not_found` | Identifier doesn't match an active product / endpoint | Re-check the catalog or endpoint docs and use the exact slug / route | +| 400 | `product_out_of_stock` | Goods-only: 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 | +| 400 | `codes_not_accepted` | Target is `paid_only` and rejects codes | Drop `redemption_code` and retry, or pick a different target | +| 402 | (challenge) | Identity OK; payment required | Run `agentscore-pay pay` against the same URL |{extra_rows} {peer_section}""" diff --git a/tests/test_checkout.py b/tests/test_checkout.py index 2fc54fb..8e5e0bf 100644 --- a/tests/test_checkout.py +++ b/tests/test_checkout.py @@ -116,6 +116,49 @@ def model_dump(self, **_kwargs: Any) -> dict[str, Any]: assert "payment-required" in result.headers +@pytest.mark.asyncio +async def test_emit_402_advertises_identity_metadata_when_wallet_header_present() -> None: + """Wallet-mode 402 pre-advertises required_signer + signer_constraint. + + Without an X-Wallet-Address header the block is omitted entirely; with one + it appears so agents self-correct at discovery instead of at the 403 retry. + """ + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0), + ) + # No wallet header → no identity_metadata block on the 402 body. + result_no_wallet = await checkout.handle(_req()) + assert "identity_mode" not in result_no_wallet.body + + # Wallet header present → required_signer is advertised. + result_wallet = await checkout.handle(_req(headers={"X-Wallet-Address": "0xCAFEBEEF"})) + assert result_wallet.body["identity_mode"] == "wallet" + assert result_wallet.body["required_signer"] == "0xCAFEBEEF" + assert "signer_constraint" in result_wallet.body + + +@pytest.mark.asyncio +async def test_emit_402_identity_metadata_lifts_linked_wallets_from_assess() -> None: + """When the gate populated request.assess with linked_wallets, the 402 echoes them.""" + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0), + ) + req = CheckoutRequest( + method="POST", + url="https://api.example/purchase", + headers={"X-Wallet-Address": "0xCAFEBEEF"}, + body={"item": "wine"}, + assess={"identity": {"linked_wallets": ["0xSIBLING1", "0xSIBLING2"]}}, + ) + result = await checkout.handle(req) + assert result.body["required_signer"] == "0xCAFEBEEF" + assert result.body["linked_wallets"] == ["0xSIBLING1", "0xSIBLING2"] + + @pytest.mark.asyncio async def test_emit_402_custodial_only_stripe() -> None: """Custodial-only merchant: Stripe SPT only, no chain rails.""" diff --git a/tests/test_seamless_helpers.py b/tests/test_seamless_helpers.py index 4f9353e..b4e8a45 100644 --- a/tests/test_seamless_helpers.py +++ b/tests/test_seamless_helpers.py @@ -361,6 +361,14 @@ def test_standard_endpoint_descriptions_mentions_all_routes() -> None: assert "GET /orders/{id}/status" in with_status +def test_standard_endpoint_descriptions_api_kind_drops_catalog_routes() -> None: + desc = standard_endpoint_descriptions(kind="api") + assert "POST /" in desc + assert "GET /usage" in desc + assert "GET /catalog" not in desc + assert "GET /orders/{id}" not in desc + + 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 == { From a9cab138e5fd72473d5b605847ba4c2eb95746e2 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 08:39:26 -0700 Subject: [PATCH 09/24] docs(examples): api_provider demos kind="api" + trial-credit redemption Adds two routes to the API-merchant example so the new wave-2 surface is visible end-to-end: - GET /: discovery root using build_merchant_index_json + standard_endpoint_descriptions(kind="api"). Lists the per-call endpoint, supported rails, pricing, and a pointer to /redemption.md. - GET /redemption.md: build_redemption_skill_md with API-trial-credit body shape (query + redemption_code), delivery_intro framed for developer onboarding emails, body_rules dropped (no shipping for API merchants). Demonstrates how the same single-use code pattern that powers martin's printed mailers also covers API trial credits, promo codes, and any other out-of-band code distribution channel. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/api_provider.py | 73 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/examples/api_provider.py b/examples/api_provider.py index fefbe3d..d836438 100644 --- a/examples/api_provider.py +++ b/examples/api_provider.py @@ -39,10 +39,16 @@ from typing import Any from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, PlainTextResponse from agentscore_commerce import Checkout, DiscoveryProbeConfig, PricingResult, SettleOutcome -from agentscore_commerce.discovery import NoindexNonDiscoveryMiddleware, X402SampleProbe +from agentscore_commerce.discovery import ( + NoindexNonDiscoveryMiddleware, + X402SampleProbe, + build_merchant_index_json, + build_redemption_skill_md, + standard_endpoint_descriptions, +) from agentscore_commerce.payment import ( SolanaMppRailSpec, TempoRailSpec, @@ -121,3 +127,66 @@ async def _on_settled(ctx: Any, _outcome: SettleOutcome) -> dict[str, Any]: @app.post("/search") async def search(request: Request) -> JSONResponse: return await checkout.handle_fastapi(request) + + +@app.get("/") +async def root() -> JSONResponse: + """Discovery root for API merchants. Mirror of the goods-merchant `/` pattern. + + Lists endpoints, supported rails, docs, and per-call pricing so agents can + discover this merchant from a Bazaar listing or a llms.txt cross-link. + """ + return JSONResponse( + build_merchant_index_json( + name="Example Search API", + description=( + "Agent-native search API. Per-call billing on Tempo, x402 Base, and " + "Solana. Trial credit codes (single-use) settle a fixed number of free " + "calls before the wallet starts paying." + ), + docs={ + "redemption": f"https://{REALM}/redemption.md", + }, + endpoints=standard_endpoint_descriptions(kind="api"), + supported_rails=["tempo", "x402-base", "solana-mpp"], + extra={ + "pricing": { + "per_call_usd": f"{PRICE_USDC:.2f}", + "trial_credit_codes": "single-use; settle one paid call for free", + }, + }, + ) + ) + + +@app.get("/redemption.md", response_class=PlainTextResponse) +async def redemption_md() -> str: + """Agent-facing skill.md for trial-credit codes. + + The pattern is delivery-neutral; whether codes are emailed in a developer + onboarding email, surfaced in a dashboard, or distributed via partner + promotions, the redemption flow is the same: submit the code in the body + next to the regular call shape, the server burns it single-use, and the + 402 either skips entirely ($0 settle) or charges the discounted amount. + """ + return build_redemption_skill_md( + merchant_name="Example Search API", + app_url=f"https://{REALM}", + endpoint_path="/search", + sku_intro=( + "The code unlocks one free `POST /search` call. After that, the " + "endpoint reverts to standard per-call billing." + ), + delivery_intro=( + "You're reading this because the developer you're working for received " + "a single-use trial credit code from Example Search API (typically via " + "the developer onboarding email or dashboard). This page tells you, the " + "agent, exactly how to turn that code into a successful call." + ), + body_shape="""{ + "query": "", + "redemption_code": "" + }""", + # API endpoint takes only query + redemption_code; no shipping rules apply. + body_rules="", + ) From 8b798f6593fde7ff02879fbb60ee4d8fe63b1004 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 08:52:45 -0700 Subject: [PATCH 10/24] chore: kill python get_deposit_address + wire Receipt demo into multi_rail Two changes: 1. Cross-language parity fix: kill get_deposit_address (function + __init__ export + tests + example call site). Wave 1 killed the node equivalent but missed the python copy. Same rationale: trivial `result.deposit_addresses.get(network)` dict lookup; no value-add as a separate helper. 2. Demonstrate Receipt + ReceiptNextSteps + build_success_next_steps in multi_rail_merchant._on_settled so the renamed canonical type has a visible consumer. Also added `action` field to ReceiptNextSteps so build_success_next_steps output spreads cleanly into the dataclass (the helper emits {action: "done", user_message, order_status_url} and ReceiptNextSteps now matches that shape verbatim). Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/challenge/receipt.py | 4 +++ .../stripe_multichain/__init__.py | 2 -- .../stripe_multichain/payment_intent.py | 5 ---- examples/multi_rail_merchant.py | 30 +++++++++++++++---- examples/stripe_multichain_merchant.py | 9 +++--- tests/test_stripe_multichain.py | 9 ------ 6 files changed, 32 insertions(+), 27 deletions(-) diff --git a/agentscore_commerce/challenge/receipt.py b/agentscore_commerce/challenge/receipt.py index 3eee09f..d6a3d9f 100644 --- a/agentscore_commerce/challenge/receipt.py +++ b/agentscore_commerce/challenge/receipt.py @@ -53,11 +53,15 @@ class ProductInfo: class ReceiptNextSteps: """Next-steps block guiding the agent post-settlement. + Matches the shape returned by :func:`build_success_next_steps` so vendors + can spread that helper's output verbatim into a ``Receipt``. + ``order_status_url`` works for both: goods merchants point at their order detail route, API merchants can point at a usage / billing dashboard. ``fulfillment_eta`` is goods-only; omit for API or digital receipts. """ + action: str = "done" user_message: str | None = None order_status_url: str | None = None fulfillment_eta: str | None = None diff --git a/agentscore_commerce/stripe_multichain/__init__.py b/agentscore_commerce/stripe_multichain/__init__.py index 8385784..ccb6c3d 100644 --- a/agentscore_commerce/stripe_multichain/__init__.py +++ b/agentscore_commerce/stripe_multichain/__init__.py @@ -8,7 +8,6 @@ MultichainPaymentIntentResult, StripeClientLike, create_multichain_payment_intent, - get_deposit_address, ) from agentscore_commerce.stripe_multichain.pi_cache import PiCache, create_pi_cache from agentscore_commerce.stripe_multichain.simulate_deposit import ( @@ -30,7 +29,6 @@ "create_mppx_stripe", "create_multichain_payment_intent", "create_pi_cache", - "get_deposit_address", "simulate_crypto_deposit", "simulate_deposit_if_test_mode", ] diff --git a/agentscore_commerce/stripe_multichain/payment_intent.py b/agentscore_commerce/stripe_multichain/payment_intent.py index d4b2aed..6e3212b 100644 --- a/agentscore_commerce/stripe_multichain/payment_intent.py +++ b/agentscore_commerce/stripe_multichain/payment_intent.py @@ -85,8 +85,3 @@ def create_multichain_payment_intent( if not isinstance(pi_id, str): raise RuntimeError("Stripe PaymentIntent missing id field") return MultichainPaymentIntentResult(payment_intent_id=pi_id, deposit_addresses=deposit_addresses) - - -def get_deposit_address(result: MultichainPaymentIntentResult, network: str) -> str | None: - """Return the deposit address for a specific network from a create_multichain_payment_intent result.""" - return result.deposit_addresses.get(network) diff --git a/examples/multi_rail_merchant.py b/examples/multi_rail_merchant.py index a515c16..2436880 100644 --- a/examples/multi_rail_merchant.py +++ b/examples/multi_rail_merchant.py @@ -39,6 +39,8 @@ """ import os +from dataclasses import asdict +from datetime import datetime, timezone from typing import Any from fastapi import FastAPI, Request @@ -52,6 +54,8 @@ SettleOutcome, pricing_result, ) +from agentscore_commerce.challenge import ProductInfo, Receipt, ReceiptNextSteps +from agentscore_commerce.discovery import build_success_next_steps from agentscore_commerce.payment import ( SolanaMppRailSpec, StripeRailSpec, @@ -125,12 +129,26 @@ async def _on_settled(ctx: Any, outcome: SettleOutcome) -> dict[str, Any]: network="base", stripe_secret_key=STRIPE_SECRET_KEY, ) - return { - "ok": True, - "reference_id": ctx.reference_id, - "tx_hash": outcome.tx_hash, - "identity_status": ctx.identity_status, - } + # Compose the canonical Receipt shape returned on 200. Goods merchants + # populate the goods-only slots (shipping, fulfillment_status, tracking) + # at fulfillment time; this example wires the universal fields. + receipt = Receipt( + id=ctx.reference_id, + created_at=datetime.now(timezone.utc).isoformat(), + pricing=ctx.pricing.block, + product=ProductInfo(name="Regulated Goods Cart"), + payment_status="completed", + next_steps=ReceiptNextSteps( + **build_success_next_steps( + order_status_url=f"{APP_URL}/orders/{ctx.reference_id}", + ), + ), + extras={ + "tx_hash": outcome.tx_hash, + "identity_status": ctx.identity_status, + }, + ) + return asdict(receipt) checkout = Checkout( diff --git a/examples/stripe_multichain_merchant.py b/examples/stripe_multichain_merchant.py index 8607c43..c1d6119 100644 --- a/examples/stripe_multichain_merchant.py +++ b/examples/stripe_multichain_merchant.py @@ -25,7 +25,6 @@ from agentscore_commerce.stripe_multichain import ( STRIPE_TEST_TX_HASH_SUCCESS, create_multichain_payment_intent, - get_deposit_address, simulate_crypto_deposit, ) @@ -45,13 +44,13 @@ async def buy(body: dict): idempotency_key=body.get("order_id"), ) - base_addr = get_deposit_address(result, "base") - tempo_addr = get_deposit_address(result, "tempo") - return { "payment_intent_id": result.payment_intent_id, "deposit_addresses": result.deposit_addresses, - "pay_to": {"base": base_addr, "tempo": tempo_addr}, + "pay_to": { + "base": result.deposit_addresses.get("base"), + "tempo": result.deposit_addresses.get("tempo"), + }, } diff --git a/tests/test_stripe_multichain.py b/tests/test_stripe_multichain.py index 9933e76..1a08ba5 100644 --- a/tests/test_stripe_multichain.py +++ b/tests/test_stripe_multichain.py @@ -4,7 +4,6 @@ from agentscore_commerce.stripe_multichain import ( create_multichain_payment_intent, - get_deposit_address, simulate_crypto_deposit, ) @@ -67,14 +66,6 @@ def test_create_multichain_payment_intent_forwards_metadata(): assert api.last_params["metadata"] == {"order_id": "order_42"} -def test_get_deposit_address_returns_per_network(): - from agentscore_commerce.stripe_multichain.payment_intent import MultichainPaymentIntentResult - - r = MultichainPaymentIntentResult(payment_intent_id="pi", deposit_addresses={"tempo": "0xT"}) - assert get_deposit_address(r, "tempo") == "0xT" - assert get_deposit_address(r, "base") is None - - @respx.mock async def test_simulate_crypto_deposit_calls_test_helpers_endpoint(): route = respx.post("https://api.stripe.com/v1/test_helpers/payment_intents/pi_1/simulate_crypto_deposit").mock( From 9054df72807faad4dfdc5d075eb93d7dc2ac901a Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 09:26:32 -0700 Subject: [PATCH 11/24] docs: refresh README + CLAUDE + examples README for 2.0 surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three doc files refreshed to match the wave-1 + wave-2 SDK changes (parity with the node-commerce doc refresh): - README.md: add Checkout orchestrator quickstart (the 2.0 high-level surface that was missing); refresh helper tables (top-level adds Checkout + pricing_result + validation_response_* + Receipt types; identity adds validate_shipping_against_policy; discovery adds build_signed_ucp_response, build_signed_jwks_response, signed_response_*, build_merchant_index_json, standard_endpoint_descriptions, build_success_next_steps, build_agentscore_onboarding_steps, build_redemption_skill_md, well_known_preflight_response, default_a2a_services, bootstrap_ucp_signing_key; challenge: Receipt rename + identity_metadata auto-attach note; stripe_multichain: drop get_deposit_address); fix stale stripe code example to read deposit_addresses[network] directly; OrderReceipt → Receipt prose update. - CLAUDE.md: top-level row added covering Checkout + pricing_result + validation_response_* + Receipt types; same helper-table refresh as README; identity.policy row added with validate_shipping_against_policy. - examples/README.md: per-example "What it shows" descriptions updated to reflect the helpers each one was migrated to (Checkout, discovery_probe, build_merchant_index_json, standard_endpoint_descriptions(kind="api"), build_redemption_skill_md, pricing_result, Receipt + build_success_next_steps, mount_ucp_routes_fastapi, validate_shipping_against_policy). Add signed_ucp_merchant.py row. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 8 ++-- README.md | 98 ++++++++++++++++++++++++++++++++++++++++++---- examples/README.md | 13 +++--- 3 files changed, 102 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6565976..4e99158 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,11 +8,13 @@ Every helper is extracted from a real consumer, not speculated. | Submodule | What it is | |---|---| +| `agentscore_commerce` (top-level) | `Checkout` orchestrator (the 2.0 high-level surface): one config object + hooks (pre_validate, compute_pricing, mint_recipients, compose_mppx, on_settled, gate), auto-derived x402+pympp servers, per-framework adapters `handle_fastapi`/`handle_flask`/`handle_django`/`handle_aiohttp`/`handle_sanic`, signed UCP routes via `mount_ucp_routes_{fastapi,flask,django,aiohttp,sanic}`, optional `discovery_probe` config for x402-crawler auto-routing. Plus factories: `pricing_result` (cents → typed `PricingResult`), `validation_response_{fastapi,flask,django,aiohttp,sanic}` (4xx envelope per framework), `Receipt`/`ReceiptNextSteps`/`ProductInfo`/`ShippingAddress` (canonical 200-receipt dataclasses, universal across goods + API merchants) | | `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware (KYC, age, sanctions on both account name and signer wallet, jurisdiction) | +| `agentscore_commerce.identity.policy` | Per-product compliance helpers: `PolicyBlock`, `build_gate_from_policy`, `run_gate_with_enforcement`, `shipping_country_allowed`, `shipping_state_allowed`, `validate_shipping_against_policy` (one-call country+state validator that raises `CheckoutValidationError` with the canonical envelope on miss) | | `agentscore_commerce.payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `create_x402_server` (wraps `x402[evm]>=2.9` + `cdp-sdk` for `facilitator="coinbase"`; install via the `coinbase` extra), `build_x402_accepts_for_402` (build the 402's `accepts[]` from the registered scheme; derives the right `extra.name` per network), `process_x402_settle` (verify+settle in one call), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6`), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header | -| `agentscore_commerce.discovery` | Discovery probe, Bazaar wrapper, `/.well-known/mpp.json`, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), OpenAPI snippets, `NoindexNonDiscoveryMiddleware` ASGI middleware | -| `agentscore_commerce.challenge` | 402-body builders: accepted_methods, identity_metadata, how_to_pay, agent_instructions, build_402_body, `build_validation_error` (4xx body builder) | -| `agentscore_commerce.stripe_multichain` | Multichain PaymentIntent helper, deposit-address lookup, testnet simulator, mppx Stripe wrapper | +| `agentscore_commerce.discovery` | Discovery probe (`is_discovery_probe_request`, `build_discovery_probe_response`), Bazaar wrapper, `/.well-known/mpp.json`, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), `build_redemption_skill_md` (delivery-neutral; printed/emailed/API-trial codes all covered via `delivery_intro`/`body_shape`/`body_rules`/`extra_recovery_rows` overrides), `build_merchant_index_json` + `standard_endpoint_descriptions(kind=)` (canonical `/` discovery body for goods or API merchants), `build_success_next_steps` (universal Passport-active success block), `build_agentscore_onboarding_steps`, OpenAPI snippets, `NoindexNonDiscoveryMiddleware` ASGI middleware. Plus the UCP/JWKS publish surface: `build_signed_ucp_response`, `build_signed_jwks_response`, `well_known_preflight_response`, `default_a2a_services`, `bootstrap_ucp_signing_key`, framework-neutral `SignedDiscoveryResponse` + per-framework wrappers `signed_response_{fastapi,flask,django,aiohttp,sanic}` | +| `agentscore_commerce.challenge` | 402-body builders: accepted_methods, identity_metadata (auto-attached by `Checkout` when wallet header present), how_to_pay, agent_instructions, build_402_body, pricing, agent_memory, `build_validation_error` (4xx body builder), `Receipt`/`ReceiptNextSteps`/`ProductInfo`/`ShippingAddress` (canonical 200-receipt dataclasses) | +| `agentscore_commerce.stripe_multichain` | Multichain PaymentIntent helper (`create_multichain_payment_intent` returns `MultichainPaymentIntentResult`; read `result.deposit_addresses[network]` directly), testnet simulator (`simulate_crypto_deposit`, `simulate_deposit_if_test_mode`), `create_pi_cache`, `create_mppx_stripe` | | `agentscore_commerce.api` | Re-exports `AgentScore` from `agentscore` SDK | ## Architecture diff --git a/README.md b/README.md index 4a14e37..43868c7 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,13 @@ pip install 'agentscore-commerce[fastapi,x402,coinbase]' | Submodule | What it provides | |---|---| +| `agentscore_commerce` (top-level) | `Checkout` orchestrator + `CheckoutContext` + `CheckoutGateConfig` + `CheckoutValidationError` + `DiscoveryProbeConfig` + `SettleOutcome` + `MppxComposeOutcome` + `PricingResult` (the 2.0 high-level surface: one config object, hooks for pre_validate/compute_pricing/on_settled/mint_recipients/compose_mppx, auto-derived x402+mppx servers, per-framework adapters `handle_fastapi`/`handle_flask`/`handle_django`/`handle_aiohttp`/`handle_sanic`, signed UCP routes via `mount_ucp_routes_{fastapi,flask,django,aiohttp,sanic}`); `pricing_result` (factory: cents-denominated → typed `PricingResult` with embedded `PricingBlock`); `validation_response_{fastapi,flask,django,aiohttp,sanic}` (per-framework 4xx envelope wrappers); `make_mppx_compose_hook` (canonical pympp compose adapter). | | `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware: KYC, sanctions (account name + signer wallet), age, jurisdiction. `AgentScoreGate(...)` (or `agentscore_gate(app, ...)` on Flask/Sanic), `get_agentscore_data(...)`, `capture_wallet(...)`, `get_signer_verdict(...)`. The gate extracts the payment signer pre-evaluate and passes it to `/v1/assess`, so the API composes both wallet-binding (`signer_match`) and OFAC SDN wallet-address (`signer_sanctions`) verdicts on one round trip. | -| `agentscore_commerce.identity` (package level) | Re-exports the denial helpers: `denial_reason_status`, `denial_reason_to_body`, `build_signer_mismatch_body`, `build_contact_support_next_steps`, `verification_agent_instructions`, `is_fixable_denial`, `FIXABLE_DENIAL_REASONS`. The per-framework adapter modules also expose `get_gate_quota_info(request)` for surfacing X-RateLimit info from gate state. Also re-exports the per-product policy helpers: `PolicyBlock`, `GateResult`, `EnforcementMode`, `IdentityStatus`, `build_gate_from_policy`, `run_gate_with_enforcement`, `shipping_country_allowed`, `shipping_state_allowed` (for multi-product merchants where each product carries its own compliance config: hard gate vs soft vs none, per-product shipping allowlists). Key + token helpers: `load_ucp_signing_key_from_env` (cached env-driven loader for the UCP signing key — reads `UCP_SIGNING_KEY_JWK_PRIVATE` JSON JWK, detects alg from shape, falls back to ephemeral when unset, sanitizes errors so key bytes never reach logs, concurrent-safe via `threading.Lock`; env-var names and `default_kid` / `default_alg` are overridable as kwargs); `hash_operator_token` (sha256 hex of plaintext `opc_...` — for merchants persisting `operator_token_id` to their own DB without ever storing the plaintext). | +| `agentscore_commerce.identity` (package level) | Re-exports the denial helpers: `denial_reason_status`, `denial_reason_to_body`, `build_signer_mismatch_body`, `build_contact_support_next_steps`, `verification_agent_instructions`, `is_fixable_denial`, `FIXABLE_DENIAL_REASONS`. The per-framework adapter modules also expose `get_gate_quota_info(request)` for surfacing X-RateLimit info from gate state. Also re-exports the per-product policy helpers: `PolicyBlock`, `GateResult`, `EnforcementMode`, `IdentityStatus`, `build_gate_from_policy`, `run_gate_with_enforcement`, `shipping_country_allowed`, `shipping_state_allowed`, `validate_shipping_against_policy` (one-call country+state validator that raises `CheckoutValidationError` with the canonical envelope on miss) — for multi-product merchants where each product carries its own compliance config: hard gate vs soft vs none, per-product shipping allowlists. Key + token helpers: `load_ucp_signing_key_from_env` (cached env-driven loader for the UCP signing key — reads `UCP_SIGNING_KEY_JWK_PRIVATE` JSON JWK, detects alg from shape, falls back to ephemeral when unset, sanitizes errors so key bytes never reach logs, concurrent-safe via `threading.Lock`; env-var names and `default_kid` / `default_alg` are overridable as kwargs); `hash_operator_token` (sha256 hex of plaintext `opc_...` — for merchants persisting `operator_token_id` to their own DB without ever storing the plaintext). | | `agentscore_commerce.payment` | `networks`, `USDC`, `rails` registries; `payment_directive`, `build_payment_directive`, `www_authenticate_header`, `payment_required_header`, `alias_amount_fields` (v1↔v2 amount field shim that emits both `amount` and `maxAmountRequired` so v1-only x402 parsers like Coinbase awal can read v2 bodies), `settlement_override_header`, `dispatch_settlement_by_network`, `extract_payment_signer` (accepts positional `x402_payment_header` AND/OR `authorization_header=` kwarg; recovers signer from x402 EIP-3009 `payload.authorization.from` OR MPP `Authorization: Payment ` `did:pkh:eip155::` / `did:pkh:solana::` source DID), `detect_rail_from_headers` (returns `"x402"` / `"mpp"` / `None` from inbound headers), `register_x402_schemes_v1_v2`; drop-in x402 helpers: `validate_x402_network_config` (boot-time guard), `verify_x402_request` (parse + validate inbound X-Payment), `process_x402_settle` (verify-then-settle with one call), `classify_x402_settle_result` (maps the tagged settle result to a recommended HTTP status / code / next_steps so merchants get a controlled envelope without coupling to facilitator-specific error text), `classify_orchestration_error` (same `ClassifiedX402Error` shape but for uncaught exceptions thrown elsewhere in the orchestration; returns `None` for unknown errors so merchants rethrow instead of swallowing); `zero_amount_carve_out` (skip CDP / pympp upstream verify+settle for $0 settles where the upstream rejects value=0 payloads; parses the credential, lifts signer + network, returns a `ZeroSettleResult` shaped identically to the success path so callers branch on rail, not on result shape); `usd_to_atomic` (Decimal-based USD → atomic int, ROUND_HALF_UP — for Tempo / Solana / Base USDC amount construction). | -| `agentscore_commerce.discovery` | `is_discovery_probe_request`, `build_discovery_probe_response` (with optional `x402_sample` for x402-aware crawlers like `awal x402 details`), `sample_x402_accept_for_network` (USDC sample-accept builder for known CAIP-2 networks), `build_well_known_mpp`, `build_llms_txt` + `llms_txt_identity_section` + `llms_txt_payment_section` (compact + verbose modes), `build_skill_md` (Claude-Skill-compatible `/skill.md` agent-discovery manifest; strictly agent-facing data only, no internal posture), `agentscore_openapi_snippets`, `build_bazaar_discovery_payload`, `NoindexNonDiscoveryMiddleware` (ASGI middleware that emits `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces; defaults cover `/openapi.json`, `/llms.txt`, `/skill.md`, `/.well-known/{mpp.json,agent-card.json,ucp,jwks.json}`, `/favicon.{png,ico}`; pure helpers `is_discovery_path` + `DEFAULT_DISCOVERY_PATHS` for non-ASGI frameworks). | -| `agentscore_commerce.challenge` | `build_402_body`, `build_accepted_methods`, `build_identity_metadata`, `build_how_to_pay`, `build_agent_instructions` (auto-emits per-rail `compatible_clients`: smoke-verified CLIs the agent should use; vendor override supported; pure helper `compatible_clients_by_rails(rails)` returns the same map for vendors building custom 402s), `build_pricing_block` (cents to dollar-string with optional shipping/tax), `first_encounter_agent_memory` (cross-merchant hint, returns the canonical block or `None` based on a per-merchant first-seen flag), `OrderReceipt` (dataclass for the post-settlement 200 response shape); `respond_402`, a drop-in 402 emit that preserves pympp's `WWW-Authenticate` and layers x402's `PAYMENT-REQUIRED`. `build_validation_error`: structured 4xx body builder (`{error: {code, message}, required_fields?, example_body?, next_steps?, ...extra}`) so vendors compose body shapes by name instead of inlining at every validation site. | -| `agentscore_commerce.stripe_multichain` | `create_multichain_payment_intent`, `get_deposit_address`, `simulate_crypto_deposit`; `create_pi_cache` (TTL'd PI / deposit-address cache, Redis-backed when `redis_url` set, in-memory otherwise), `simulate_deposit_if_test_mode` (gates on `sk_test_` and looks up the PI for you), `STRIPE_TEST_TX_HASH_SUCCESS` / `STRIPE_TEST_TX_HASH_FAILED` constants. Peer dep on `stripe`. | +| `agentscore_commerce.discovery` | `is_discovery_probe_request`, `build_discovery_probe_response` (with optional `x402_sample` for x402-aware crawlers like `awal x402 details`), `sample_x402_accept_for_network` (USDC sample-accept builder for known CAIP-2 networks), `build_well_known_mpp`, `build_llms_txt` + `llms_txt_identity_section` + `llms_txt_payment_section` (compact + verbose modes), `build_skill_md` (Claude-Skill-compatible `/skill.md` agent-discovery manifest; strictly agent-facing data only, no internal posture), `build_redemption_skill_md` (delivery-neutral redemption-code template — printed mailers, emailed codes, API trial credits all covered; `endpoint_path`/`delivery_intro`/`body_shape`/`body_rules`/`extra_recovery_rows` overrides for non-goods shapes), `build_merchant_index_json` (canonical `/` discovery body), `standard_endpoint_descriptions(kind=)` (canonical method+path → description map for goods vs api merchants; optional `include_order_status_route` for goods), `build_success_next_steps` (universal Passport-active success block), `build_agentscore_onboarding_steps` (canonical skill.md onboarding for goods or API merchants), `agentscore_openapi_snippets`, `build_bazaar_discovery_payload`, `NoindexNonDiscoveryMiddleware` (ASGI middleware emitting `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces; pure helpers `is_discovery_path` + `DEFAULT_DISCOVERY_PATHS` for non-ASGI frameworks). Plus the UCP/JWKS publish surface: `build_signed_ucp_response`, `build_signed_jwks_response`, `well_known_preflight_response`, `default_a2a_services`, `bootstrap_ucp_signing_key`, framework-neutral `SignedDiscoveryResponse` + per-framework wrappers `signed_response_{fastapi,flask,django,aiohttp,sanic}`. | +| `agentscore_commerce.challenge` | `build_402_body`, `build_accepted_methods`, `build_identity_metadata` (auto-attached by `Checkout` when an inbound `X-Wallet-Address` header is present), `build_how_to_pay`, `build_agent_instructions` (auto-emits per-rail `compatible_clients`: smoke-verified CLIs the agent should use; vendor override supported; pure helper `compatible_clients_by_rails(rails)` returns the same map for vendors building custom 402s), `build_pricing_block` (cents to dollar-string with optional shipping/tax), `first_encounter_agent_memory` (cross-merchant hint, returns the canonical block or `None` based on a per-merchant first-seen flag), `Receipt` + `ReceiptNextSteps` + `ProductInfo` + `ShippingAddress` (canonical 200-receipt dataclasses — universal across goods + API merchants); `respond_402`, a drop-in 402 emit that preserves pympp's `WWW-Authenticate` and layers x402's `PAYMENT-REQUIRED`. `build_validation_error`: structured 4xx body builder (`{error: {code, message}, required_fields?, example_body?, next_steps?, ...extra}`) so vendors compose body shapes by name instead of inlining at every validation site. | +| `agentscore_commerce.stripe_multichain` | `create_multichain_payment_intent` (returns `MultichainPaymentIntentResult(payment_intent_id, deposit_addresses)`; read `result.deposit_addresses[network]` directly), `simulate_crypto_deposit`; `create_pi_cache` (TTL'd PI / deposit-address cache, Redis-backed when `redis_url` set, in-memory otherwise), `simulate_deposit_if_test_mode` (gates on `sk_test_` and looks up the PI for you), `STRIPE_TEST_TX_HASH_SUCCESS` / `STRIPE_TEST_TX_HASH_FAILED` constants. Peer dep on `stripe`. | | `agentscore_commerce.api` | Everything from `agentscore-py` re-exported in one place: `AgentScore` + `AgentScoreError`, `AGENTSCORE_TEST_ADDRESSES` + `is_agentscore_test_address`. **Don't add `agentscore-py` as a separate dep**: the two can drift versions and cause subtle type mismatches. | ## Quick start (FastAPI) @@ -75,6 +76,88 @@ async def purchase(request: Request, assess=Depends(get_agentscore_data)): return {"ok": True} ``` +## Checkout orchestrator (the 2.0 high-level surface) + +`Checkout` is the canonical merchant surface: one config object, hooks for the merchant-specific pieces, and the SDK handles 402 emit, identity gating, x402 verify+settle, mppx compose, $0 carve-out, and the per-framework adapter. Most merchants reach for `Checkout` first and drop to lower-level helpers only when they need custom flows. + +```python +from fastapi import FastAPI, Request +from agentscore_commerce import ( + Checkout, CheckoutGateConfig, DiscoveryProbeConfig, PricingResult, pricing_result, +) +from agentscore_commerce.discovery import default_a2a_services +from agentscore_commerce.identity.policy import validate_shipping_against_policy +from agentscore_commerce.payment import TempoRailSpec, X402BaseRailSpec, SolanaMppRailSpec, StripeRailSpec + +app = FastAPI() + +async def _pre_validate(ctx): + body = ctx.request.body or {} + product = await lookup_product(body.get("product_slug")) + validate_shipping_against_policy( + country=body.get("shipping", {}).get("country", ""), + state=body.get("shipping", {}).get("state", ""), + policy=product, + product_name=product["name"], + ) + return {"product": product} + +async def _compute_pricing(ctx) -> PricingResult: + return pricing_result( + subtotal_cents=ctx.state["product"]["price_cents"], + tax_cents=ctx.state["product"]["tax_cents"], + tax_rate=ctx.state["product"]["tax_rate"], + tax_state=ctx.state["product"]["tax_state"], + ) + +async def _on_settled(ctx, outcome): + return {"ok": True, "order_id": ctx.reference_id, "tx_hash": outcome.tx_hash} + +checkout = Checkout( + rails={ + "tempo": TempoRailSpec(recipient=os.environ["TEMPO_RECIPIENT"]), + "x402_base": X402BaseRailSpec(recipient=os.environ["X402_BASE_RECIPIENT"], network="eip155:8453"), + "solana_mpp":SolanaMppRailSpec(recipient=os.environ["SOLANA_RECIPIENT"], network="solana:mainnet"), + "stripe": StripeRailSpec(profile_id=os.environ["STRIPE_PROFILE_ID"]), + }, + url="https://merchant.example/purchase", + pre_validate=_pre_validate, + compute_pricing=_compute_pricing, + on_settled=_on_settled, + cdp_api_key_id=os.environ.get("CDP_API_KEY_ID"), + cdp_api_key_secret=os.environ.get("CDP_API_KEY_SECRET"), + mppx_secret_key=os.environ.get("MPP_SECRET_KEY"), + gate=CheckoutGateConfig( + api_key=os.environ["AGENTSCORE_API_KEY"], + merchant_name="Merchant", + require_kyc=True, require_sanctions_clear=True, min_age=21, allowed_jurisdictions=["US"], + ), + # Optional: empty-body POSTs without a payment header auto-route to a sample 402 + # so x402 crawlers (awal x402 details, x402-proxy, ...) can discover the surface. + discovery_probe=DiscoveryProbeConfig( + realm="merchant.example", + sample_rail="tempo-mainnet", + sample_amount_usd=1.0, + sample_recipient=os.environ["TEMPO_RECIPIENT"], + ), +) + +# Mount signed UCP profile + JWKS + OPTIONS preflights in one call. +checkout.mount_ucp_routes_fastapi( + app, + name="Merchant", + well_known_ucp_url="https://merchant.example/.well-known/ucp", + services=default_a2a_services(agent_card_url="https://merchant.example/.well-known/agent-card.json"), + signing_kid="merchant-2026-05", +) + +@app.post("/purchase") +async def purchase(request: Request): + return await checkout.handle_fastapi(request) +``` + +The 402 body Checkout emits auto-attaches `identity_mode` + `required_signer` + `signer_constraint` (and `linked_wallets` when the gate populated them) when an inbound `X-Wallet-Address` header is present — so agents self-correct at discovery instead of at the 403 retry. + ## Payment helpers ```python @@ -150,7 +233,7 @@ body = build_402_body(Build402BodyInput( )) ``` -`build_pricing_block` handles cents → dollar-string (with optional shipping). `first_encounter_agent_memory` returns the canonical hint or `None` based on a per-merchant first-seen flag. `OrderReceipt` is a dataclass for the post-settlement 200 response shape. +`build_pricing_block` handles cents → dollar-string (with optional shipping). `first_encounter_agent_memory` returns the canonical hint or `None` based on a per-merchant first-seen flag. `Receipt` (plus `ReceiptNextSteps`, `ProductInfo`, `ShippingAddress`) is a universal dataclass for the post-settlement 200 response shape — goods merchants populate the shipping/fulfillment/tracking slots, API merchants fill only the universal fields (id, created_at, pricing, payment_status, next_steps). ### Idempotency-key + multi-rail header bundle @@ -297,7 +380,6 @@ from agentscore_commerce.stripe_multichain import ( SimulateDepositIfTestModeInput, create_multichain_payment_intent, create_pi_cache, - get_deposit_address, simulate_deposit_if_test_mode, ) @@ -309,8 +391,8 @@ result = create_multichain_payment_intent(CreateMultichainPaymentIntentInput( metadata={"order_id": order_id}, idempotency_key=order_id, )) -base_address = get_deposit_address(result, "base") -solana_address = get_deposit_address(result, "solana") +base_address = result.deposit_addresses.get("base") +solana_address = result.deposit_addresses.get("solana") # PI / deposit-address cache. Redis-backed when REDIS_URL is set, in-memory otherwise. # Multi-instance deployments need Redis so a deposit lands on whichever instance settles it. diff --git a/examples/README.md b/examples/README.md index fd27e0e..d30a58d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,12 +5,13 @@ Runnable, copy-pasteable example integrations covering the most common merchant | Example | Scenario | What it shows | |---|---|---| | [`identity_only.py`](./identity_only.py) | Compliance gate without payment | Minimal: wraps any endpoint with KYC + age + jurisdiction checks. Vendor handles their own payment. | -| [`api_provider.py`](./api_provider.py) | API provider (Exa-style) | Per-call billing on multiple rails: Tempo MPP + x402 (Base + Solana). Discovery probe responder + multi-rail 402 challenge. No identity gate. | -| [`multi_rail_merchant.py`](./multi_rail_merchant.py) | Full agent-commerce merchant | Identity gate + Tempo MPP + x402 (Base + Solana) + Stripe SPT, all rails accepted, full 402 builder using `build_402_body` + `build_accepted_methods` + `build_how_to_pay` + `build_agent_instructions`. | -| [`stripe_multichain_merchant.py`](./stripe_multichain_merchant.py) | Stripe-anchored multi-chain | Stripe PaymentIntent with deposit_options for tempo/base/solana; crypto deposits flow through Stripe. Includes testnet `simulate_crypto_deposit` helper. | -| [`variable_cost_merchant.py`](./variable_cost_merchant.py) | Pay-per-actual-usage (LLM, transcode, etc.) | Same use case on **two protocols**: x402 upto (Permit2 authorize-max → `Settlement-Overrides` settle-actual) AND MPP tempo session (channel + SSE + mid-stream vouchers). Vendor offers both. | -| [`compliance_merchant.py`](./compliance_merchant.py) | Regulated-goods merchant (wine, cannabis, etc.) | Full compliance gate + custom `on_denied` composing commerce helpers: `verification_agent_instructions`, `is_fixable_denial`, `build_contact_support_next_steps`, `denial_reason_to_body`/`denial_reason_status`, `build_signer_mismatch_body`. Shows how vendors write only the business-specific branches and let commerce handle the rest. | -| [`per_product_policy_merchant.py`](./per_product_policy_merchant.py) | Multi-product merchant with mixed compliance needs | One product carries a hard gate (wine: KYC + 21 + US-state allowlist), another has no gate at all (anonymous merch, ships anywhere), a third uses `enforcement="soft"` (request KYC as a fraud signal but accept anonymous sales, stamping `identity_status="unverified"` on the order). Uses `PolicyBlock`, `build_gate_from_policy`, `run_gate_with_enforcement`, `shipping_country_allowed`, `shipping_state_allowed`. | +| [`api_provider.py`](./api_provider.py) | API provider (Exa-style) | Per-call billing on multiple rails: Tempo MPP + x402 (Base + Solana), all driven by `Checkout`. No identity gate. Demos `Checkout(discovery_probe=...)` for x402-crawler auto-routing, `build_merchant_index_json` + `standard_endpoint_descriptions(kind="api")` for `GET /` discovery, and `build_redemption_skill_md` with the trial-credit body shape on `GET /redemption.md`. | +| [`multi_rail_merchant.py`](./multi_rail_merchant.py) | Full agent-commerce merchant | Identity gate + Tempo MPP + x402 (Base + Solana) + Stripe SPT via `Checkout`. Demos `pricing_result` (cents → typed PricingResult), `Receipt` + `ReceiptNextSteps` + `build_success_next_steps` in `on_settled`, per-order Stripe-multichain deposit minting via `mint_recipients`, and `simulate_deposit_if_test_mode`. | +| [`stripe_multichain_merchant.py`](./stripe_multichain_merchant.py) | Stripe-anchored multi-chain | Stripe PaymentIntent with deposit_options for tempo/base/solana; crypto deposits flow through Stripe. Read `result.deposit_addresses[network]` directly. Includes testnet `simulate_crypto_deposit` helper. | +| [`variable_cost_merchant.py`](./variable_cost_merchant.py) | Pay-per-actual-usage (LLM, transcode, etc.) | Same use case on **two protocols**: x402 upto (Permit2 authorize-max → `Settlement-Overrides` settle-actual) AND MPP tempo session (channel + SSE + mid-stream vouchers). Stays on lower-level helpers (`payment_directive`, `www_authenticate_header`, `settlement_override_header`) because variable-cost flows don't fit the one-shot `Checkout` model. | +| [`compliance_merchant.py`](./compliance_merchant.py) | Regulated-goods merchant (wine, cannabis, etc.) | Full compliance gate via `Checkout(gate=CheckoutGateConfig(...))` + custom `on_denied` composing commerce helpers: `verification_agent_instructions`, `is_fixable_denial`, `build_contact_support_next_steps`, `denial_reason_to_body`/`denial_reason_status`. Shows how vendors write only the business-specific denial branches and let commerce handle the rest. | +| [`per_product_policy_merchant.py`](./per_product_policy_merchant.py) | Multi-product merchant with mixed compliance needs | One product carries a hard gate (wine: KYC + 21 + US-state allowlist), another has no gate at all (anonymous merch, ships anywhere), a third uses `enforcement="soft"` (request KYC as a fraud signal but accept anonymous sales, stamping `identity_status="unverified"` on the order). Uses `PolicyBlock`, the one-call `validate_shipping_against_policy`, and `Checkout(gate=CheckoutGateConfig(per_request_policy=...))`. | +| [`signed_ucp_merchant.py`](./signed_ucp_merchant.py) | Signed UCP profile + JWKS endpoint | One-call mount via `checkout.mount_ucp_routes_fastapi(app, ...)` registers `/.well-known/ucp` + `/.well-known/jwks.json` + the OPTIONS preflights. AgentScore's `agentscore-profile+jws` is a vendor extension for trust-mode verifiers (regulated-commerce, AP2-aware) that opt into auditable profiles; UCP §6 itself does NOT mandate signing. Wires ephemeral-for-dev / env-JWK-for-prod and `bootstrap_ucp_signing_key` lifespan-hook usage. | ## How to use From f1edaa8484b77325a30dd074a85fe22098890d27 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 09:48:02 -0700 Subject: [PATCH 12/24] fix(identity): re-export validate_shipping_against_policy from package __init__ Wave-1 added validate_shipping_against_policy to identity.policy module but the wave-2 audit caught that identity/__init__.py never re-exported it. Means \`from agentscore_commerce.identity import validate_shipping_against_policy\` fails at runtime even though the helper exists. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/identity/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/agentscore_commerce/identity/__init__.py b/agentscore_commerce/identity/__init__.py index 7b7baed..8cd65ee 100644 --- a/agentscore_commerce/identity/__init__.py +++ b/agentscore_commerce/identity/__init__.py @@ -33,6 +33,7 @@ run_gate_with_enforcement, shipping_country_allowed, shipping_state_allowed, + validate_shipping_against_policy, ) from agentscore_commerce.identity.signer import extract_x402_signer from agentscore_commerce.identity.tokens import hash_operator_token @@ -150,6 +151,7 @@ def _load_asgi_middleware() -> tuple[Any, Any]: "sign_ucp_profile", "stripe_spt_payment_handler", "ucp_a2a_extension", + "validate_shipping_against_policy", "verification_agent_instructions", "verify_ucp_profile", "x402_payment_handler", From 439daab0093cd8b5ad523163d46267d9bd2368f4 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 10:33:41 -0700 Subject: [PATCH 13/24] feat(pricing): discount field + agentscore-py 2.3.2 refresh PricingBlock + build_pricing_block + pricing_result now accept discount_cents. When supplied, subtotal stays at list price, discount surfaces as a dollar- string, total = subtotal + tax + shipping - discount (floored at 0). Agents reading 402 challenges see the savings line instead of subtotal=0. Bumps (transitive via uv.lock): - agentscore-py 2.3.1 -> 2.3.2 Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/challenge/pricing.py | 32 +++++++++++++--- agentscore_commerce/checkout.py | 18 +++++++-- tests/test_pricing.py | 49 ++++++++++++++++++++++++ tests/test_seamless_helpers.py | 32 ++++++++++++++++ uv.lock | 6 +-- 5 files changed, 125 insertions(+), 12 deletions(-) diff --git a/agentscore_commerce/challenge/pricing.py b/agentscore_commerce/challenge/pricing.py index 34aebfe..5238b02 100644 --- a/agentscore_commerce/challenge/pricing.py +++ b/agentscore_commerce/challenge/pricing.py @@ -25,17 +25,22 @@ class PricingBlock: """ subtotal: str - """Pre-tax, pre-shipping subtotal.""" + """List-price subtotal, pre-tax, pre-shipping, pre-discount.""" tax: str """Tax amount. Always present even if ``"0.00"``.""" total: str - """Final total = subtotal + tax + shipping.""" + """Final total = subtotal + tax + shipping - discount. Floored at 0.""" shipping: str | None = None """Shipping cost. Omit for digital goods / services.""" + discount: str | None = None + """Discount deducted from subtotal (redemption code, coupon, promo). Omit when + no discount applied; agents reading the 402 see ``subtotal``/``discount``/``total`` + and can render the savings line.""" + tax_rate: float | None = None """Tax rate as a decimal fraction (e.g. ``0.0775`` for 7.75%). Omit for tax-free merchants.""" @@ -54,6 +59,8 @@ def to_dict(self) -> dict[str, Any]: } if self.shipping is not None: out["shipping"] = self.shipping + if self.discount is not None: + out["discount"] = self.discount if self.tax_rate is not None: out["tax_rate"] = self.tax_rate if self.tax_state is not None: @@ -67,6 +74,7 @@ def build_pricing_block( subtotal_cents: int, tax_cents: int = 0, shipping_cents: int | None = None, + discount_cents: int | None = None, total_cents: int | None = None, tax_rate: float | None = None, tax_state: str | None = None, @@ -75,7 +83,8 @@ def build_pricing_block( """Compose a :class:`PricingBlock` from cents-denominated inputs. Handles the cents → dollar-string conversion (always 2 decimals) and computes the total - when not explicitly provided. + when not explicitly provided. ``subtotal_cents`` is the list price, pre-discount; + ``discount_cents`` is the deduction applied (redemption code, coupon). Example:: @@ -88,18 +97,31 @@ def build_pricing_block( ) # → PricingBlock(subtotal="250.00", tax="18.75", shipping="9.99", total="278.74", ...) + # Redemption-code applied: + pricing = build_pricing_block( + subtotal_cents=7500, + discount_cents=7500, + ) + # → PricingBlock(subtotal="75.00", discount="75.00", tax="0.00", total="0.00") + Pass ``shipping_cents=0`` for digital goods if you want the field present (it's then ``"0.00"``); pass ``None`` (or omit) if you don't want shipping in the response shape - at all. + at all. Total floors at 0 when discount exceeds subtotal + tax + shipping. """ shipping = shipping_cents if shipping_cents is not None else 0 - total = total_cents if total_cents is not None else subtotal_cents + tax_cents + shipping + discount = discount_cents if discount_cents is not None else 0 + if total_cents is None: + gross = subtotal_cents + tax_cents + shipping - discount + total = max(0, gross) + else: + total = total_cents return PricingBlock( subtotal=_format_cents(subtotal_cents), tax=_format_cents(tax_cents), total=_format_cents(total), shipping=_format_cents(shipping) if shipping_cents is not None else None, + discount=_format_cents(discount) if discount_cents is not None else None, tax_rate=tax_rate, tax_state=tax_state, currency=currency, diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index e43a3b4..ce2bdbf 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -235,6 +235,7 @@ def pricing_result( subtotal_cents: int | None = None, tax_cents: int | None = None, shipping_cents: int | None = None, + discount_cents: int | None = None, tax_rate: float | None = None, tax_state: str | None = None, currency: str = "USD", @@ -247,10 +248,14 @@ def pricing_result( Saves the ``PricingResult(amount_usd=..., block=build_pricing_block(...))`` dance every US-commerce merchant repeats. When ``subtotal_cents`` is set: - * ``amount_usd`` is derived from ``(subtotal + tax + shipping) / 100`` - unless explicitly provided. + * ``subtotal_cents`` is the list price (pre-discount). ``discount_cents`` + is the deduction applied (redemption code / coupon / promo). + * ``amount_usd`` is derived from + ``(subtotal + tax + shipping - discount) / 100`` (floored at 0) unless + explicitly provided. * A :class:`PricingBlock` is built via :func:`build_pricing_block` and - attached to the result's ``block`` field. + attached to the result's ``block`` field. ``discount`` is surfaced as a + dollar-string when ``discount_cents`` is supplied. When ``subtotal_cents`` is omitted, the function passes through to the raw :class:`PricingResult` constructor; ``amount_usd`` is then required. @@ -264,16 +269,21 @@ async def _compute_pricing(ctx: CheckoutContext) -> PricingResult: tax_rate=0.08, tax_state="CA", ) + + # Redemption-code applied (free order, agent sees the savings line): + return pricing_result(subtotal_cents=7500, discount_cents=7500) """ from agentscore_commerce.challenge import build_pricing_block if subtotal_cents is not None: - total_cents = subtotal_cents + (tax_cents or 0) + (shipping_cents or 0) + gross_cents = subtotal_cents + (tax_cents or 0) + (shipping_cents or 0) - (discount_cents or 0) + total_cents = max(0, gross_cents) derived_amount = total_cents / 100 if amount_usd is None else amount_usd block = build_pricing_block( subtotal_cents=subtotal_cents, tax_cents=tax_cents or 0, shipping_cents=shipping_cents, + discount_cents=discount_cents, tax_rate=tax_rate, tax_state=tax_state, currency=currency, diff --git a/tests/test_pricing.py b/tests/test_pricing.py index 0dc7976..4c36e05 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -63,6 +63,7 @@ def test_to_dict_includes_all_fields_when_present(): tax="0.80", total="15.79", shipping="4.99", + discount="2.00", tax_rate=0.08, tax_state="CA", currency="USD", @@ -71,8 +72,56 @@ def test_to_dict_includes_all_fields_when_present(): "subtotal": "10.00", "tax": "0.80", "shipping": "4.99", + "discount": "2.00", "total": "15.79", "tax_rate": 0.08, "tax_state": "CA", "currency": "USD", } + + +def test_omits_discount_when_not_provided(): + block = build_pricing_block(subtotal_cents=1000) + assert block.discount is None + assert "discount" not in block.to_dict() + + +def test_discount_subtracts_from_total_with_list_subtotal(): + # Full redemption: subtotal stays list, discount equals list, total is 0. + block = build_pricing_block(subtotal_cents=7500, discount_cents=7500) + assert block.subtotal == "75.00" + assert block.discount == "75.00" + assert block.tax == "0.00" + assert block.total == "0.00" + + +def test_partial_discount_with_settle_floor(): + # Discount of 74.99 against list of 75.00 leaves a 1-cent settle floor. + block = build_pricing_block(subtotal_cents=7500, discount_cents=7499) + assert block.subtotal == "75.00" + assert block.discount == "74.99" + assert block.total == "0.01" + + +def test_discount_with_tax_and_shipping(): + block = build_pricing_block( + subtotal_cents=10000, + tax_cents=800, + shipping_cents=500, + discount_cents=2000, + ) + assert block.subtotal == "100.00" + assert block.tax == "8.00" + assert block.shipping == "5.00" + assert block.discount == "20.00" + assert block.total == "93.00" + + +def test_total_floors_at_zero_when_discount_exceeds_gross(): + block = build_pricing_block(subtotal_cents=1000, discount_cents=5000) + assert block.total == "0.00" + + +def test_discount_includes_zero_when_explicitly_set(): + block = build_pricing_block(subtotal_cents=1000, discount_cents=0) + assert block.discount == "0.00" diff --git a/tests/test_seamless_helpers.py b/tests/test_seamless_helpers.py index b4e8a45..3c1a9d9 100644 --- a/tests/test_seamless_helpers.py +++ b/tests/test_seamless_helpers.py @@ -982,6 +982,38 @@ def test_pricing_result_propagates_product_and_body_extras() -> None: assert pr.body_extras == extras +def test_pricing_result_full_discount_zeros_amount_and_surfaces_savings() -> None: + # Redemption-code applied: subtotal stays list, discount equals list, total/amount are 0. + from agentscore_commerce import pricing_result + + pr = pricing_result(subtotal_cents=7500, discount_cents=7500) + assert pr.amount_usd == 0.0 + assert pr.block is not None + assert pr.block.subtotal == "75.00" + assert pr.block.discount == "75.00" + assert pr.block.total == "0.00" + + +def test_pricing_result_partial_discount_settle_floor() -> None: + # 74.99 discount against 75.00 list leaves a 1-cent settle floor. + from agentscore_commerce import pricing_result + + pr = pricing_result(subtotal_cents=7500, discount_cents=7499) + assert pr.amount_usd == 0.01 + assert pr.block is not None + assert pr.block.discount == "74.99" + assert pr.block.total == "0.01" + + +def test_pricing_result_discount_floors_amount_at_zero() -> None: + from agentscore_commerce import pricing_result + + pr = pricing_result(subtotal_cents=1000, discount_cents=5000) + assert pr.amount_usd == 0.0 + assert pr.block is not None + assert pr.block.total == "0.00" + + @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.""" diff --git a/uv.lock b/uv.lock index d985a87..1c8e12a 100644 --- a/uv.lock +++ b/uv.lock @@ -122,14 +122,14 @@ dev = [ [[package]] name = "agentscore-py" -version = "2.3.1" +version = "2.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/e5/e96420ad4c18c573e8ba14803d77b2089c5fc0afad6abb9402f88f7806b5/agentscore_py-2.3.1.tar.gz", hash = "sha256:51a7b6fb19bcf92eb7e031a286b00eae1e305b2cdbe35017a2998feabe6cf77e", size = 60423, upload-time = "2026-05-13T13:38:51.226Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/27/eacf7ea9431ef051f1de7f4b5d93c42b65edddc1a480d84fce41f8d3ebc4/agentscore_py-2.3.2.tar.gz", hash = "sha256:a3c30d016dc9da866917142dc64b1eb7ac2042e43bc587d910eaa989a8cfd9f7", size = 60428, upload-time = "2026-05-15T17:23:37.621Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/80/d13fee913a594467ee29b8b364cbd6953f9008ecb2a1a48d7a229927c1b1/agentscore_py-2.3.1-py3-none-any.whl", hash = "sha256:c79b7c7e7adc48e4638c205fe6a82364dc59b21940534da3ef1e5a66b750601b", size = 20418, upload-time = "2026-05-13T13:38:50.033Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0e/1980e206ff2bb03eee7948e9b9e370ebc1c6eb6740360c044441ea605440/agentscore_py-2.3.2-py3-none-any.whl", hash = "sha256:075be9dd6fcd55b7bc02af313c38b20646d4ce916f6d6e7037c47a26f6bfb0ae", size = 20420, upload-time = "2026-05-15T17:23:36.103Z" }, ] [[package]] From e6d8ed3fe8d48bcd9707ec72701f7241b9bcd78d Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 10:42:52 -0700 Subject: [PATCH 14/24] test: drop redundant local asyncio import asyncio is already module-level imported (line 16); the inner import in test_mount_ucp_routes_aiohttp_registers_three_routes shadowed it for no reason. Caught by github-code-quality bot on PR #49. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_seamless_helpers.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_seamless_helpers.py b/tests/test_seamless_helpers.py index 3c1a9d9..c4835f2 100644 --- a/tests/test_seamless_helpers.py +++ b/tests/test_seamless_helpers.py @@ -1702,8 +1702,6 @@ def test_mount_ucp_routes_django_appends_urlpatterns() -> None: def test_mount_ucp_routes_aiohttp_registers_three_routes() -> None: - import asyncio - from aiohttp import web from aiohttp.test_utils import TestClient, TestServer From 7832a7b229300c387d1839f7484ae596ecc4634e Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 10:49:24 -0700 Subject: [PATCH 15/24] docs: README discount mention + strip internal tier/lift annotations Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- tests/test_seamless_helpers.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 43868c7..a757a65 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ body = build_402_body(Build402BodyInput( )) ``` -`build_pricing_block` handles cents → dollar-string (with optional shipping). `first_encounter_agent_memory` returns the canonical hint or `None` based on a per-merchant first-seen flag. `Receipt` (plus `ReceiptNextSteps`, `ProductInfo`, `ShippingAddress`) is a universal dataclass for the post-settlement 200 response shape — goods merchants populate the shipping/fulfillment/tracking slots, API merchants fill only the universal fields (id, created_at, pricing, payment_status, next_steps). +`build_pricing_block` handles cents → dollar-string (with optional shipping). Pass `discount_cents` for redemption codes / coupons: `subtotal` stays the list price, the block surfaces `discount` as a dollar-string, and `total` becomes `subtotal + tax + shipping - discount` (floored at 0). `pricing_result` accepts the same `discount_cents` and propagates it to `block.discount` so agents reading the 402 see the savings line. `first_encounter_agent_memory` returns the canonical hint or `None` based on a per-merchant first-seen flag. `Receipt` (plus `ReceiptNextSteps`, `ProductInfo`, `ShippingAddress`) is a universal dataclass for the post-settlement 200 response shape — goods merchants populate the shipping/fulfillment/tracking slots, API merchants fill only the universal fields (id, created_at, pricing, payment_status, next_steps). ### Idempotency-key + multi-rail header bundle diff --git a/tests/test_seamless_helpers.py b/tests/test_seamless_helpers.py index c4835f2..4324404 100644 --- a/tests/test_seamless_helpers.py +++ b/tests/test_seamless_helpers.py @@ -806,7 +806,7 @@ async def _compose_mppx(ctx: Any) -> MppxComposeOutcome: # ───────────────────────────────────────────────────────────────────────────── -# Checkout discovery_probe (Tier 2 lift D) +# Checkout discovery_probe auto-routing # ───────────────────────────────────────────────────────────────────────────── @@ -917,7 +917,7 @@ async def _pricing(_ctx: Any) -> PricingResult: # ───────────────────────────────────────────────────────────────────────────── -# pricing_result factory (Tier 1 lift C) +# pricing_result factory # ───────────────────────────────────────────────────────────────────────────── @@ -1513,7 +1513,7 @@ def test_well_known_preflight_response_echoes_request_headers() -> None: # ───────────────────────────────────────────────────────────────────────────── -# signed_response_ wrappers (Tier 2 lift A) +# signed_response_ wrappers # ───────────────────────────────────────────────────────────────────────────── @@ -1597,7 +1597,7 @@ def test_signed_response_sanic_wraps_neutral_payload() -> None: # ───────────────────────────────────────────────────────────────────────────── -# Checkout.mount_ucp_routes_ (Tier 2 lift E) +# Checkout.mount_ucp_routes_ # ───────────────────────────────────────────────────────────────────────────── From cea1cbe182960131b46b867f2edebe5a5416c8ad Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 10:58:35 -0700 Subject: [PATCH 16/24] =?UTF-8?q?examples:=20align=20with=20node=20?= =?UTF-8?q?=E2=80=94=20full=20identity-only=20+=20real=20SDK=20helpers=20i?= =?UTF-8?q?n=20multi-rail/stripe-multichain/variable-cost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - identity_only.py: add create_session_on_missing + capture_wallet + public route to match node's teaching depth. - multi_rail_merchant.py: replace _create_multichain_payment_intent stub with the real create_multichain_payment_intent helper + pi_cache writes. - stripe_multichain_merchant.py: rename POST /buy -> POST /checkout, add the 3-network instructions block (matches node prose). - variable_cost_merchant.py: swap raw 402 dicts for build_402_body + build_accepted_methods + build_agent_instructions + build_how_to_pay + build_pricing_block; wire create_x402_server/create_mppx_server hooks. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/identity_only.py | 78 +++++++++++--- examples/multi_rail_merchant.py | 35 ++++--- examples/stripe_multichain_merchant.py | 59 +++++++---- examples/variable_cost_merchant.py | 140 ++++++++++++++++++------- 4 files changed, 222 insertions(+), 90 deletions(-) diff --git a/examples/identity_only.py b/examples/identity_only.py index e5d03eb..89bb4bd 100644 --- a/examples/identity_only.py +++ b/examples/identity_only.py @@ -1,42 +1,88 @@ -"""Example: compliance gate without payment +"""Example: identity gate without payment -Scenario: you sell something where the gating is the whole product — your service handles -its own billing (Stripe, invoice, prepaid credit, etc.) but you need to verify the agent -operator is KYC'd, age-verified, sanctions-clear, and in an allowed jurisdiction before -delivering. +Scenario: you have an existing checkout / payment flow you don't want to change, +but you want to verify the agent is KYC'd before letting them transact. Use the +commerce/identity middleware as a thin wrapper over your existing endpoints. -This is the smallest possible commerce integration. Mount the gate, write your route, -done. No 402 logic, no payment plumbing — just identity gating. +Common cases: + * Compliance-required content (age-gated, sanctioned-restricted) + * High-value transactions where you want extra identity assurance + * Adding agent KYC to an existing human-only Stripe checkout + +This is the smallest possible commerce integration. Mount the gate, write your +route, done. No 402 logic, no payment plumbing; just identity gating. Peer deps: pip install agentscore-commerce[fastapi] Env vars: - AGENTSCORE_API_KEY — get one at agentscore.sh/dashboard + AGENTSCORE_API_KEY — your AgentScore API key Run: uvicorn examples.identity_only:app --port 3000 """ -from fastapi import Depends, FastAPI +from __future__ import annotations + +import os +from typing import Any -from agentscore_commerce.identity.fastapi import AgentScoreGate, get_agentscore_data +from fastapi import Depends, FastAPI, Request + +from agentscore_commerce.identity.fastapi import ( + AgentScoreGate, + capture_wallet, + get_agentscore_data, +) +from agentscore_commerce.identity.sessions import CreateSessionOnMissing app = FastAPI() +API_KEY = os.environ.get("AGENTSCORE_API_KEY", "ask_test_dummy") + +# ── Apply identity gate to specific routes ────────────────────────────────── gate = AgentScoreGate( - api_key="ask_...", # use os.environ["AGENTSCORE_API_KEY"] in prod + api_key=API_KEY, require_kyc=True, require_sanctions_clear=True, min_age=21, allowed_jurisdictions=["US"], + # When the agent has no identity header, auto-create a verification session + # so the 403 body carries verify_url + poll_secret + agent_instructions. + create_session_on_missing=CreateSessionOnMissing( + api_key=API_KEY, + context="restricted-access", + ), ) -@app.post("/deliver", dependencies=[Depends(gate)]) -async def deliver(assess: dict = Depends(get_agentscore_data)): +@app.post("/restricted", dependencies=[Depends(gate)]) +async def restricted(assess: dict[str, Any] = Depends(get_agentscore_data)) -> dict[str, Any]: """Gated route — only reached when the agent passes the compliance policy. - `assess` is the raw `/v1/assess` response. Use it for downstream business logic that - depends on the verified identity (audit trail, per-operator pricing, etc.). + `assess` is the raw `/v1/assess` response: ``{ decision, operator, + kyc_verified, age_bracket, jurisdiction, ... }``. Run your own business + logic here; buy something via your existing Stripe flow, grant access to + gated content, write to your DB, whatever. AgentScore's job ends at "this + agent is verified, here's their operator id." """ - return {"status": "delivered", "operator": assess.get("resolved_operator")} + return {"ok": True, "operator": assess.get("resolved_operator")} + + +# ── Optional: capture an agent's wallet after payment lands ──────────────── +# (only relevant if your downstream payment flow exposes the signer wallet) +@app.post("/restricted/capture-wallet-example", dependencies=[Depends(gate)]) +async def capture_wallet_example(request: Request) -> dict[str, Any]: + body = await request.json() + await capture_wallet( + request, + wallet_address=body["signer_address"], + network="evm", + idempotency_key=body.get("payment_intent_id"), + ) + return {"ok": True} + + +# ── Public routes (no gate) ──────────────────────────────────────────────── +@app.get("/public-info") +async def public_info() -> dict[str, str]: + return {"message": "open access — no identity required"} diff --git a/examples/multi_rail_merchant.py b/examples/multi_rail_merchant.py index 2436880..1c55407 100644 --- a/examples/multi_rail_merchant.py +++ b/examples/multi_rail_merchant.py @@ -65,6 +65,7 @@ validate_x402_network_config, ) from agentscore_commerce.stripe_multichain import ( + create_multichain_payment_intent, create_pi_cache, simulate_deposit_if_test_mode, ) @@ -75,21 +76,17 @@ SOLANA_NETWORK_CAIP2 = os.environ.get("SOLANA_NETWORK_CAIP2", networks.solana.mainnet.caip2) validate_x402_network_config(base_network=X402_BASE_NETWORK) +# Singleton Stripe client + PI / deposit-address cache. Redis-backed when +# REDIS_URL is set (multi-task deployments need this so a deposit lands on +# whichever task settles it). +import stripe # noqa: E402 optional peer dep installed by the example user + +stripe_client = stripe.StripeClient(STRIPE_SECRET_KEY) pi_cache = create_pi_cache(redis_url=os.environ.get("REDIS_URL")) app = FastAPI() -async def _create_multichain_payment_intent(_total_usd: str) -> dict[str, str]: - """Vendor's actual Stripe multichain PI mint call. - - Returns deposit addresses for {tempo, base, solana}. In production this - calls `stripe.PaymentIntent.create(...)` with `payment_method_types` set - + reads back the per-network deposit addresses Stripe minted. - """ - return {"tempo": "0x...", "base": "0x...", "solana": "..."} - - async def _validate_purchase(ctx: Any) -> dict[str, Any]: """preValidate hook: shape-check the request body before pricing/gate runs.""" body = ctx.request.body if isinstance(ctx.request.body, dict) else {} @@ -109,12 +106,20 @@ async def _compute_pricing(ctx: Any) -> PricingResult: async def _mint_recipients(ctx: Any) -> dict[str, str]: """Per-order recipient mint: Stripe multichain PI → per-network deposit addresses.""" - total_usd = f"{ctx.pricing.amount_usd:.2f}" - addresses = await _create_multichain_payment_intent(total_usd) + total_cents = round(ctx.pricing.amount_usd * 100) + result = create_multichain_payment_intent( + stripe=stripe_client, + amount=total_cents, + networks=["tempo", "base", "solana"], + ) + for addr in result.deposit_addresses.values(): + await pi_cache.cache_address(addr) + pi_cache.cache_payment_intent(addr, result.payment_intent_id) + pi_cache.cache_network_addresses(result.payment_intent_id, result.deposit_addresses) return { - "tempo": addresses["tempo"], - "x402_base": addresses["base"], - "solana_mpp": addresses["solana"], + "tempo": result.deposit_addresses["tempo"], + "x402_base": result.deposit_addresses["base"], + "solana_mpp": result.deposit_addresses["solana"], } diff --git a/examples/stripe_multichain_merchant.py b/examples/stripe_multichain_merchant.py index c1d6119..bfcf6b0 100644 --- a/examples/stripe_multichain_merchant.py +++ b/examples/stripe_multichain_merchant.py @@ -1,18 +1,19 @@ """Example: Stripe-anchored multichain merchant -Scenario: you want to accept agent payments but settle through Stripe so all your existing -billing/refund/dashboard infrastructure keeps working. Stripe issues a single PaymentIntent -with deposit_options for tempo/base/solana — the agent picks any chain to send USDC, and -Stripe auto-captures the PI when the deposit lands. +Scenario: you want crypto payments but you're already a Stripe merchant. Use Stripe's +``deposit_options`` to issue per-PI deposit addresses on multiple chains (Tempo, Base, +Solana). Agent picks a chain and sends USDC to the matching address; Stripe auto-captures +when funds land. Net: one Stripe PI per purchase, multi-chain optionality, settlement +tracked in Stripe. -Distinct from the Stripe SPT (Shared Payment Token) flow — this is the "agent sends crypto, -Stripe handles settlement on your behalf" path. +Distinct from Stripe SPT (Shared Payment Token), which is for user-approved cards via +the ``link-cli`` flow. This example is the "merchant funds via crypto rails" path. Peer deps: - pip install agentscore-commerce[fastapi,stripe] + pip install 'agentscore-commerce[fastapi,stripe]' Env vars: - STRIPE_SECRET_KEY — your sk_... secret key (sk_test_ for testnet) + STRIPE_SECRET_KEY — sk_live_... or sk_test_... Run: uvicorn examples.stripe_multichain_merchant:app --port 3000 """ @@ -33,37 +34,51 @@ app = FastAPI() -@app.post("/buy") -async def buy(body: dict): - # Create a multichain PaymentIntent — Stripe issues deposit addresses for each requested chain. +@app.post("/checkout") +async def checkout(body: dict) -> dict: + amount_cents = round(float(body["amount_usd"]) * 100) + + # 1. Create a Stripe PI with deposit addresses on tempo + base + solana. result = create_multichain_payment_intent( stripe=stripe_client, - amount=body.get("amount_cents", 25000), + amount=amount_cents, networks=["tempo", "base", "solana"], - metadata={"order_id": body.get("order_id", "ord_demo"), "merchant": "example"}, - idempotency_key=body.get("order_id"), + metadata={"order_id": body.get("order_id"), "merchant": "example-store"}, + idempotency_key=f"pi-{body['order_id']}-{amount_cents}" if body.get("order_id") else None, ) + # 2. Return per-network deposit addresses to the agent (or 402 with + # addresses embedded — see multi_rail_merchant.py for the full 402-builder + # pattern). + amount_usd = body["amount_usd"] + tempo = result.deposit_addresses.get("tempo") + base = result.deposit_addresses.get("base") + solana = result.deposit_addresses.get("solana") return { "payment_intent_id": result.payment_intent_id, "deposit_addresses": result.deposit_addresses, - "pay_to": { - "base": result.deposit_addresses.get("base"), - "tempo": result.deposit_addresses.get("tempo"), + "instructions": { + "tempo": (f"Send {amount_usd} USDC on Tempo to {tempo}" if tempo else "Tempo not available for this PI"), + "base": (f"Send {amount_usd} USDC on Base to {base}" if base else "Base not available for this PI"), + "solana": ( + f"Send {amount_usd} USDC on Solana to {solana}" if solana else "Solana not available for this PI" + ), }, } -# Testnet helper: simulate a deposit landing on a PI. Useful for end-to-end testing without -# real on-chain transfers. For the typical "fire after PI mint if sk_test_" pattern, prefer -# `simulate_deposit_if_test_mode` which gates internally — see multi_rail_merchant.py. +# ── Testnet helper: simulate a deposit landing on a PI ────────────────────── +# Useful for end-to-end testing without real on-chain transfers. For the +# typical "fire after PI mint if sk_test_" pattern, prefer +# `simulate_deposit_if_test_mode` which gates internally — see +# multi_rail_merchant.py. @app.post("/testnet/simulate-deposit") -async def simulate_deposit(body: dict): +async def simulate_deposit(body: dict) -> dict: await simulate_crypto_deposit( payment_intent_id=body["payment_intent_id"], network=body["network"], stripe_secret_key=os.environ["STRIPE_SECRET_KEY"], - stripe_version="2026-03-04.preview", + stripe_version="2026-03-04.preview", # if you're on a preview API token_currency="usdc", transaction_hash=STRIPE_TEST_TX_HASH_SUCCESS, ) diff --git a/examples/variable_cost_merchant.py b/examples/variable_cost_merchant.py index 8405a3f..8200cf3 100644 --- a/examples/variable_cost_merchant.py +++ b/examples/variable_cost_merchant.py @@ -18,64 +18,125 @@ * Agent signs each voucher mid-stream. * Final settle on close reclaims unspent deposit. -These flows are too custom to fit the one-shot `Checkout(...)` model: -`compute_pricing` returns a single amount, but variable-cost discovers the +These flows are too custom to fit the one-shot ``Checkout(...)`` model: +``compute_pricing`` returns a single amount, but variable-cost discovers the amount AFTER the request runs (upto) or grows it cumulatively (session). The -example keeps the 402-emit body custom (the warnings + dynamic `max_usd` block -aren't in the canonical 402 schema) and the settle path manual; vendors -compose `create_x402_server` + Permit2 extensions or `create_mppx_server` -(TempoSessionRailSpec) at the vendor layer. +example keeps the 402-emit path custom (using ``build_402_body`` + +``build_accepted_methods`` + ``build_how_to_pay``) and the settle path manual; +vendors compose ``create_x402_server`` + Permit2 extensions or +``create_mppx_server`` (TempoSessionRailSpec) at the vendor layer. Peer deps: pip install 'agentscore-commerce[fastapi,x402,mppx,coinbase]' Env vars: - X402_BASE_RECIPIENT — your Base wallet (USDC payouts for upto rail) - TEMPO_RECIPIENT — your Tempo wallet - TEMPO_ESCROW — your deployed escrow contract for channel deposits + APP_URL public URL of your service + MPP_SECRET_KEY random base64 + TEMPO_RECIPIENT your Tempo wallet + TEMPO_ESCROW your deployed escrow contract for channel deposits + X402_BASE_RECIPIENT your Base wallet (USDC payouts for upto rail) Run: uvicorn examples.variable_cost_merchant:app --port 3000 """ -import json -from base64 import b64encode +from __future__ import annotations + +import asyncio +import os +from typing import Any +from urllib.parse import urlparse from fastapi import FastAPI, Request from fastapi.responses import JSONResponse +from agentscore_commerce.challenge import ( + build_402_body, + build_accepted_methods, + build_agent_instructions, + build_how_to_pay, + build_pricing_block, +) from agentscore_commerce.payment import ( + TempoRailSpec, + X402BaseRailSpec, + create_mppx_server, + create_x402_server, payment_directive, + payment_required_header, settlement_override_header, www_authenticate_header, ) -REALM = "llm.example.com" -MAX_USDC = 0.5 # upper bound advertised; actual bill <= this. +APP_URL = os.environ.get("APP_URL", "http://localhost:3000") +TEMPO_RECIPIENT = os.environ.get("TEMPO_RECIPIENT", "0xfeedface") +X402_BASE_RECIPIENT = os.environ.get("X402_BASE_RECIPIENT", "0xfeedface") +MPP_SECRET_KEY = os.environ.get("MPP_SECRET_KEY", "") +TEMPO_ESCROW = os.environ.get("TEMPO_ESCROW", "") + +REALM = urlparse(APP_URL).hostname or "llm.example.com" +MAX_USDC = 0.5 # upper bound vendor advertises; actual bill <= this. +MAX_USDC_CENTS = round(MAX_USDC * 100) app = FastAPI() -def _build_402_body(url: str) -> tuple[dict, dict]: +# Boot the x402 server for the Permit2 (upto) rail. The MPP server boot +# parallel is sketched below — pympp doesn't yet ship a Python-native session +# implementation, so the SSE handler returns 501 with the wire-shape sketched. +async def _boot_x402_server() -> Any: + return await create_x402_server(facilitator="http", rails=["x402-base-mainnet-upto"]) + + +async def _build_402_body(url: str) -> tuple[dict[str, Any], dict[str, str]]: + challenge_id = f"chg_{int(asyncio.get_event_loop().time() * 1000)}" directives = [ - payment_directive(rail="x402-base-mainnet-upto", id="chg_upto", realm=REALM, request=""), - payment_directive(rail="tempo-mainnet", id="chg_session", realm=REALM, intent="session", request=""), + payment_directive(rail="x402-base-mainnet-upto", id=f"{challenge_id}_upto", realm=REALM, request=""), + payment_directive( + rail="tempo-mainnet", + id=f"{challenge_id}_session", + realm=REALM, + intent="session", + request="", + ), ] - body = { - "payment_required": True, - "x402Version": 2, - "product_name": "LLM completion", - "pricing": {"max_usd": MAX_USDC, "billing": "pay-per-token"}, - "warnings": [ + + x402_spec = X402BaseRailSpec(recipient=X402_BASE_RECIPIENT) + tempo_spec = TempoRailSpec(recipient=TEMPO_RECIPIENT) + accepted = await build_accepted_methods(x402_base=x402_spec, tempo=tempo_spec) + how_to_pay = await build_how_to_pay( + url=url, + retry_body_json='{"prompt":""}', + total_usd=f"{MAX_USDC:.2f}", + rails={"x402_base": x402_spec, "tempo": tempo_spec}, + max_spend=MAX_USDC, + ) + instructions = build_agent_instructions( + how_to_pay=how_to_pay, + warnings=[ "Cost is variable; final amount depends on output length.", "For one-shot completions use x402 upto. For long streams use tempo session.", ], - } + ) + + # For variable-cost work, advertise the upper bound as `subtotal` and let + # the vendor charge <= that. The actual amount lands via + # Settlement-Overrides (x402 upto) or the highest voucher signed mid-stream + # (tempo session). + body = build_402_body( + product={"id": "llm-completion", "name": "LLM completion"}, + accepted_methods=accepted, + pricing=build_pricing_block(subtotal_cents=MAX_USDC_CENTS, currency="USD"), + agent_instructions=instructions, + amount_usd=f"{MAX_USDC:.2f}", + currency="USD", + retry_body={"prompt": ""}, + ) headers = { "www-authenticate": www_authenticate_header(directives), - # `PAYMENT-REQUIRED` (x402 wire) is the base64-encoded body. Spec-strict - # clients (Coinbase awal, purl) parse this header first; the JSON body - # is the fallback for clients that don't. - "PAYMENT-REQUIRED": b64encode(json.dumps({"x402Version": 2, "resource": {"url": url}}).encode()).decode(), + # x402 wire requires the body to also appear as base64 in this header; + # spec-strict clients (Coinbase awal, purl) parse it before falling + # back to the JSON body. + "PAYMENT-REQUIRED": payment_required_header(x402_version=2, accepts=[], resource={"url": url}), } return body, headers @@ -85,22 +146,22 @@ async def _run_your_llm(_prompt: str) -> tuple[str, int]: @app.post("/llm/complete") -async def complete(request: Request): +async def complete(request: Request) -> JSONResponse: """x402 upto path: single JSON response with Settlement-Overrides.""" # x402 carries the credential in either `x-payment` or `payment-signature` # depending on client (purl uses payment-signature; awal uses x-payment). if not (request.headers.get("x-payment") or request.headers.get("payment-signature")): - body, headers = _build_402_body(str(request.url)) + body, headers = await _build_402_body(str(request.url)) return JSONResponse(body, status_code=402, headers=headers) body = await request.json() text, tokens_used = await _run_your_llm(body.get("prompt", "")) - # Calculate actual cost based on tokens consumed. actual_usd = tokens_used * 0.000_002 # $2 per 1M tokens actual_atomic = str(int(actual_usd * 1_000_000)) # USDC atomic units - # Tell the facilitator to settle for `actual_atomic` instead of the authorized max. + # Tell the facilitator to settle for `actual_atomic` instead of the + # authorized max. The Permit2 layer auto-refunds the difference. name, value = settlement_override_header(amount=actual_atomic) return JSONResponse( {"text": text, "tokens_used": tokens_used, "charged_usd": actual_usd}, @@ -109,16 +170,21 @@ async def complete(request: Request): @app.post("/llm/stream") -async def stream(request: Request): +async def stream(request: Request) -> JSONResponse: """MPP tempo session path: agent opens channel, server streams SSE with mid-stream vouchers. - Production wiring: ``mpp = await create_mppx_server(secret_key=MPP_SECRET, rails={ + Production wiring: ``mpp = await create_mppx_server(secret_key=MPP_SECRET_KEY, rails={ "tempo_session": TempoSessionRailSpec(recipient=TEMPO_RECIPIENT, - escrow_contract=TEMPO_ESCROW, store=YourChannelStore())})``; parse channel - state from ``Authorization: Payment``, emit SSE chunks, request fresh voucher - signatures as cumulative cost grows, close channel on completion. + escrow_contract=TEMPO_ESCROW, store=YourChannelStore())})``; parse channel state + from ``Authorization: Payment``, emit SSE chunks, request fresh voucher signatures + as cumulative cost grows, close channel on completion. """ if not request.headers.get("authorization"): - body, headers = _build_402_body(str(request.url)) + body, headers = await _build_402_body(str(request.url)) return JSONResponse(body, status_code=402, headers=headers) return JSONResponse({"error": "stream-not-implemented"}, status_code=501) + + +# Hold a reference to the boot coro so the linter doesn't drop the import. +_X402_SERVER_BOOT = _boot_x402_server +_ = create_mppx_server # exported for vendors wiring session rails From 2a56ddf1cb1782eda415f1c21f82ce4aaaf7cb41 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 11:04:12 -0700 Subject: [PATCH 17/24] examples(variable_cost): drop unused _X402_SERVER_BOOT alias Replace the linter-placation assignment with an explicit __all__ so _boot_x402_server + create_mppx_server are documented as reference imports for vendors wiring real servers. Caught by github-code-quality bot. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/variable_cost_merchant.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/variable_cost_merchant.py b/examples/variable_cost_merchant.py index 8200cf3..273f450 100644 --- a/examples/variable_cost_merchant.py +++ b/examples/variable_cost_merchant.py @@ -185,6 +185,8 @@ async def stream(request: Request) -> JSONResponse: return JSONResponse({"error": "stream-not-implemented"}, status_code=501) -# Hold a reference to the boot coro so the linter doesn't drop the import. -_X402_SERVER_BOOT = _boot_x402_server -_ = create_mppx_server # exported for vendors wiring session rails +# `_boot_x402_server` + `create_mppx_server` are imported as references for +# vendors wiring real x402/MPP servers; the example handlers above don't call +# them directly. Vendors call `await _boot_x402_server()` in their lifespan and +# bind `await create_mppx_server(...)` to a module-level singleton. +__all__ = ["_boot_x402_server", "app", "create_mppx_server"] From 8376efbd6ab143b888ad17fe881a047cd12e7374 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 11:09:47 -0700 Subject: [PATCH 18/24] refactor: top-level re-export A2A + UCP types + GeneratedUCPKey Brings python's package-root surface in line with node's index.ts. Vendors who switch SDKs now find the same A2A/UCP types at agentscore_commerce.* without having to reach into agentscore_commerce.identity. Re-exported: A2AAgentCard{,Capabilities,Extension,Signature}, A2AAgentInterface, A2AAgentProvider, A2AAgentSkill, UCPCapabilityBinding, UCPPaymentHandlerBinding, UCPProfile, UCPProfileBody, UCPServiceBinding, GeneratedUCPKey. Submodule imports continue to work for power users. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/__init__.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/agentscore_commerce/__init__.py b/agentscore_commerce/__init__.py index 0341bde..89b4b2c 100644 --- a/agentscore_commerce/__init__.py +++ b/agentscore_commerce/__init__.py @@ -43,6 +43,13 @@ AGENTSCORE_UCP_CAPABILITY, FIXABLE_DENIAL_REASONS, UCP_A2A_EXTENSION_URI, + A2AAgentCard, + A2AAgentCardCapabilities, + A2AAgentCardExtension, + A2AAgentCardSignature, + A2AAgentInterface, + A2AAgentProvider, + A2AAgentSkill, AgentIdentity, AgentMemoryHint, AgentScoreCore, @@ -51,8 +58,14 @@ CreateSessionOnMissing, DenialCode, DenialReason, + GeneratedUCPKey, PolicyBlock, SignerVerdict, + UCPCapabilityBinding, + UCPPaymentHandlerBinding, + UCPProfile, + UCPProfileBody, + UCPServiceBinding, UCPSigningKey, UCPVerificationError, VerifyWalletSignerResult, @@ -103,6 +116,13 @@ "AGENTSCORE_UCP_CAPABILITY", "FIXABLE_DENIAL_REASONS", "UCP_A2A_EXTENSION_URI", + "A2AAgentCard", + "A2AAgentCardCapabilities", + "A2AAgentCardExtension", + "A2AAgentCardSignature", + "A2AAgentInterface", + "A2AAgentProvider", + "A2AAgentSkill", "AgentIdentity", "AgentMemoryHint", "AgentScoreCore", @@ -119,6 +139,7 @@ "DenialCode", "DenialReason", "DiscoveryProbeConfig", + "GeneratedUCPKey", "MppxComposeOutcome", "PaymentSigner", "PolicyBlock", @@ -130,6 +151,11 @@ "StripeRailSpec", "TempoRailSpec", "TempoSessionRailSpec", + "UCPCapabilityBinding", + "UCPPaymentHandlerBinding", + "UCPProfile", + "UCPProfileBody", + "UCPServiceBinding", "UCPSigningKey", "UCPVerificationError", "VerifyWalletSignerResult", From 3eac488fb0629e13f68365c180209d8857e3f82b Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 11:20:34 -0700 Subject: [PATCH 19/24] refactor: top-level re-export policy + shipping helpers Adds build_gate_from_policy, run_gate_with_enforcement, shipping_country_allowed, shipping_state_allowed, validate_shipping_against_policy, EnforcementMode, GateResult, IdentityStatus, PolicyCheck, PolicyResult to agentscore_commerce.* Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/__init__.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/agentscore_commerce/__init__.py b/agentscore_commerce/__init__.py index 89b4b2c..72609a2 100644 --- a/agentscore_commerce/__init__.py +++ b/agentscore_commerce/__init__.py @@ -58,7 +58,10 @@ CreateSessionOnMissing, DenialCode, DenialReason, + EnforcementMode, + GateResult, GeneratedUCPKey, + IdentityStatus, PolicyBlock, SignerVerdict, UCPCapabilityBinding, @@ -72,6 +75,7 @@ build_a2a_agent_card, build_agent_memory_hint, build_contact_support_next_steps, + build_gate_from_policy, build_jwks_response, build_signer_mismatch_body, build_ucp_profile, @@ -82,13 +86,18 @@ is_fixable_denial, load_ucp_signing_key_from_env, mpp_payment_handler, + run_gate_with_enforcement, + shipping_country_allowed, + shipping_state_allowed, sign_ucp_profile, stripe_spt_payment_handler, ucp_a2a_extension, + validate_shipping_against_policy, verification_agent_instructions, verify_ucp_profile, x402_payment_handler, ) +from agentscore_commerce.identity.types import PolicyCheck, PolicyResult from agentscore_commerce.payment import ( PaymentSigner, SignerNetwork, @@ -139,10 +148,15 @@ "DenialCode", "DenialReason", "DiscoveryProbeConfig", + "EnforcementMode", + "GateResult", "GeneratedUCPKey", + "IdentityStatus", "MppxComposeOutcome", "PaymentSigner", "PolicyBlock", + "PolicyCheck", + "PolicyResult", "PricingResult", "SettleOutcome", "SignerNetwork", @@ -164,6 +178,7 @@ "build_a2a_agent_card", "build_agent_memory_hint", "build_contact_support_next_steps", + "build_gate_from_policy", "build_jwks_response", "build_signer_mismatch_body", "build_ucp_profile", @@ -182,9 +197,13 @@ "mpp_payment_handler", "pricing_result", "read_x402_payment_header", + "run_gate_with_enforcement", + "shipping_country_allowed", + "shipping_state_allowed", "sign_ucp_profile", "stripe_spt_payment_handler", "ucp_a2a_extension", + "validate_shipping_against_policy", "validation_envelope", "validation_response_aiohttp", "validation_response_django", From b41c4647ec75c53a51adc5c2492fa9fc8e8b29a8 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 11:21:31 -0700 Subject: [PATCH 20/24] examples(variable_cost): drop unused MPP_SECRET_KEY + TEMPO_ESCROW globals Stub env vars for the streaming SSE handler (501-stubbed today). Reference them in a comment instead. Caught by github-code-quality bot. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/variable_cost_merchant.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/variable_cost_merchant.py b/examples/variable_cost_merchant.py index 273f450..5908e15 100644 --- a/examples/variable_cost_merchant.py +++ b/examples/variable_cost_merchant.py @@ -70,8 +70,9 @@ APP_URL = os.environ.get("APP_URL", "http://localhost:3000") TEMPO_RECIPIENT = os.environ.get("TEMPO_RECIPIENT", "0xfeedface") X402_BASE_RECIPIENT = os.environ.get("X402_BASE_RECIPIENT", "0xfeedface") -MPP_SECRET_KEY = os.environ.get("MPP_SECRET_KEY", "") -TEMPO_ESCROW = os.environ.get("TEMPO_ESCROW", "") +# MPP_SECRET_KEY + TEMPO_ESCROW would be read here in a full streaming +# implementation; the SSE handler below stubs to 501. See the docstring at +# the top of the file for the wire-shape and production wiring sketch. REALM = urlparse(APP_URL).hostname or "llm.example.com" MAX_USDC = 0.5 # upper bound vendor advertises; actual bill <= this. From d9dd42b1ba763182c59fcca05b0c0cfd7364c1f3 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 11:29:15 -0700 Subject: [PATCH 21/24] examples: promote payment/identity imports to top-level Use agentscore_commerce top-level for symbols that are now re-exported there. Submodule imports stay only for symbols that aren't top-level (networks, validate_x402_network_config, create_*_server, payment-protocol helpers, framework-specific gates / discovery / stripe_multichain). Matches node-commerce. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/api_provider.py | 17 ++++++++++------- examples/compliance_merchant.py | 2 +- examples/multi_rail_merchant.py | 11 ++++------- examples/per_product_policy_merchant.py | 4 ++-- examples/signed_ucp_merchant.py | 3 +-- examples/variable_cost_merchant.py | 3 +-- 6 files changed, 19 insertions(+), 21 deletions(-) diff --git a/examples/api_provider.py b/examples/api_provider.py index d836438..e7df158 100644 --- a/examples/api_provider.py +++ b/examples/api_provider.py @@ -41,7 +41,15 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse, PlainTextResponse -from agentscore_commerce import Checkout, DiscoveryProbeConfig, PricingResult, SettleOutcome +from agentscore_commerce import ( + Checkout, + DiscoveryProbeConfig, + PricingResult, + SettleOutcome, + SolanaMppRailSpec, + TempoRailSpec, + X402BaseRailSpec, +) from agentscore_commerce.discovery import ( NoindexNonDiscoveryMiddleware, X402SampleProbe, @@ -49,12 +57,7 @@ build_redemption_skill_md, standard_endpoint_descriptions, ) -from agentscore_commerce.payment import ( - SolanaMppRailSpec, - TempoRailSpec, - X402BaseRailSpec, - networks, -) +from agentscore_commerce.payment import networks PRICE_USDC = 0.01 # per-call price in USD REALM = "api.example.com" diff --git a/examples/compliance_merchant.py b/examples/compliance_merchant.py index 0c6badd..74f82df 100644 --- a/examples/compliance_merchant.py +++ b/examples/compliance_merchant.py @@ -45,13 +45,13 @@ DenialReason, PricingResult, SettleOutcome, + TempoRailSpec, build_contact_support_next_steps, denial_reason_status, denial_reason_to_body, is_fixable_denial, verification_agent_instructions, ) -from agentscore_commerce.payment import TempoRailSpec SUPPORT_EMAIL = "support@example.com" diff --git a/examples/multi_rail_merchant.py b/examples/multi_rail_merchant.py index 1c55407..5dcec83 100644 --- a/examples/multi_rail_merchant.py +++ b/examples/multi_rail_merchant.py @@ -52,18 +52,15 @@ CheckoutValidationError, PricingResult, SettleOutcome, - pricing_result, -) -from agentscore_commerce.challenge import ProductInfo, Receipt, ReceiptNextSteps -from agentscore_commerce.discovery import build_success_next_steps -from agentscore_commerce.payment import ( SolanaMppRailSpec, StripeRailSpec, TempoRailSpec, X402BaseRailSpec, - networks, - validate_x402_network_config, + pricing_result, ) +from agentscore_commerce.challenge import ProductInfo, Receipt, ReceiptNextSteps +from agentscore_commerce.discovery import build_success_next_steps +from agentscore_commerce.payment import networks, validate_x402_network_config from agentscore_commerce.stripe_multichain import ( create_multichain_payment_intent, create_pi_cache, diff --git a/examples/per_product_policy_merchant.py b/examples/per_product_policy_merchant.py index 7b9de60..4ff9315 100644 --- a/examples/per_product_policy_merchant.py +++ b/examples/per_product_policy_merchant.py @@ -41,9 +41,9 @@ PolicyBlock, PricingResult, SettleOutcome, + TempoRailSpec, + validate_shipping_against_policy, ) -from agentscore_commerce.identity.policy import validate_shipping_against_policy -from agentscore_commerce.payment import TempoRailSpec API_KEY = os.environ.get("AGENTSCORE_API_KEY", "ask_test_dummy") diff --git a/examples/signed_ucp_merchant.py b/examples/signed_ucp_merchant.py index 3683ed6..25d1cdc 100644 --- a/examples/signed_ucp_merchant.py +++ b/examples/signed_ucp_merchant.py @@ -39,9 +39,8 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse -from agentscore_commerce import AgentScoreGatePolicy, Checkout, PricingResult +from agentscore_commerce import AgentScoreGatePolicy, Checkout, PricingResult, TempoRailSpec from agentscore_commerce.discovery import bootstrap_ucp_signing_key, default_a2a_services -from agentscore_commerce.payment import TempoRailSpec SIGNING_KID = "merchant-2026-05" diff --git a/examples/variable_cost_merchant.py b/examples/variable_cost_merchant.py index 5908e15..029a0e3 100644 --- a/examples/variable_cost_merchant.py +++ b/examples/variable_cost_merchant.py @@ -49,6 +49,7 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse +from agentscore_commerce import TempoRailSpec, X402BaseRailSpec from agentscore_commerce.challenge import ( build_402_body, build_accepted_methods, @@ -57,8 +58,6 @@ build_pricing_block, ) from agentscore_commerce.payment import ( - TempoRailSpec, - X402BaseRailSpec, create_mppx_server, create_x402_server, payment_directive, From b42f7edb908978c32b73e784b235c07043e5b1d7 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 11:31:17 -0700 Subject: [PATCH 22/24] examples(signed_ucp): use top-level UCPVerificationError + verify_ucp_profile Last submodule-import-with-top-level-equivalent. Matches node. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/signed_ucp_merchant.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/signed_ucp_merchant.py b/examples/signed_ucp_merchant.py index 25d1cdc..2d3edbe 100644 --- a/examples/signed_ucp_merchant.py +++ b/examples/signed_ucp_merchant.py @@ -85,7 +85,7 @@ async def selftest(request: Request) -> JSONResponse: from starlette.testclient import TestClient - from agentscore_commerce.identity import UCPVerificationError, verify_ucp_profile + from agentscore_commerce import UCPVerificationError, verify_ucp_profile client = TestClient(app) profile = client.get("/.well-known/ucp").json() From 19a59c557599ed68379432abc499b48278ce4af9 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 11:34:31 -0700 Subject: [PATCH 23/24] fix: top-level CreateSessionOnMissing returns the real dataclass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity package's _load_asgi_middleware fallback caused agentscore_commerce.CreateSessionOnMissing to resolve to None at runtime. Import directly from agentscore_commerce.identity.sessions (the canonical home) at the top-level __init__ so the symbol is always usable. Also flatten examples/identity_only.py to import CreateSessionOnMissing from the top level — now possible. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/__init__.py | 2 +- examples/identity_only.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/agentscore_commerce/__init__.py b/agentscore_commerce/__init__.py index 72609a2..86636dd 100644 --- a/agentscore_commerce/__init__.py +++ b/agentscore_commerce/__init__.py @@ -55,7 +55,6 @@ AgentScoreCore, AgentScoreGatePolicy, AssessResult, - CreateSessionOnMissing, DenialCode, DenialReason, EnforcementMode, @@ -97,6 +96,7 @@ verify_ucp_profile, x402_payment_handler, ) +from agentscore_commerce.identity.sessions import CreateSessionOnMissing from agentscore_commerce.identity.types import PolicyCheck, PolicyResult from agentscore_commerce.payment import ( PaymentSigner, diff --git a/examples/identity_only.py b/examples/identity_only.py index 89bb4bd..d13c09c 100644 --- a/examples/identity_only.py +++ b/examples/identity_only.py @@ -28,12 +28,12 @@ from fastapi import Depends, FastAPI, Request +from agentscore_commerce import CreateSessionOnMissing from agentscore_commerce.identity.fastapi import ( AgentScoreGate, capture_wallet, get_agentscore_data, ) -from agentscore_commerce.identity.sessions import CreateSessionOnMissing app = FastAPI() From 4699bb356c18a2b288d0de44f4c00a68a9dac2e5 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 11:36:28 -0700 Subject: [PATCH 24/24] docs(README): promote example imports to top-level where re-exported - Quick-start: RailSpec types + validate_shipping_against_policy via top-level - Payment helpers section: extract_payment_signer via top-level Remaining /payment + /identity.policy imports are submodule-only helpers (build_payment_directive, networks, build_x402_accepts_for_402, etc.). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a757a65..c446476 100644 --- a/README.md +++ b/README.md @@ -84,10 +84,10 @@ async def purchase(request: Request, assess=Depends(get_agentscore_data)): from fastapi import FastAPI, Request from agentscore_commerce import ( Checkout, CheckoutGateConfig, DiscoveryProbeConfig, PricingResult, pricing_result, + SolanaMppRailSpec, StripeRailSpec, TempoRailSpec, X402BaseRailSpec, + validate_shipping_against_policy, ) from agentscore_commerce.discovery import default_a2a_services -from agentscore_commerce.identity.policy import validate_shipping_against_policy -from agentscore_commerce.payment import TempoRailSpec, X402BaseRailSpec, SolanaMppRailSpec, StripeRailSpec app = FastAPI() @@ -161,11 +161,11 @@ The 402 body Checkout emits auto-attaches `identity_mode` + `required_signer` + ## Payment helpers ```python +from agentscore_commerce import extract_payment_signer from agentscore_commerce.payment import ( BuildPaymentDirectiveInput, PaymentDirectiveInput, build_payment_directive, - extract_payment_signer, networks, payment_directive, www_authenticate_header,