From 9f5e5a7ed97d653f7c3d95e3f14926d80ae2ba5b Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 18 May 2026 21:39:50 -0700 Subject: [PATCH 1/6] =?UTF-8?q?feat:=202.1.1=20=E2=80=94=20typed=20errors?= =?UTF-8?q?=20+=20bootstrap=20session-mint=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the node-commerce 2.1.1 changes for cross-language parity. 1. Typed errors in stripe-multichain + dispatch helpers - `pay_to_address.py`: malformed Authorization: Payment, cache-miss recipient, missing recipient field → CheckoutValidationError(401, `invalid_credential`, action=`retry_without_credential`). `pay_to_address` fallback → 503 `payment_provider_unavailable`. - `payment_intent.py`: Stripe returns an empty `deposit_addresses` map → 503 `payment_provider_unavailable`. - `payment/dispatch.py`: unregistered EVM/Solana handler or unrecognized network family → 503 `payment_provider_unavailable`. 2. CheckoutValidationError extraction to its own module `agentscore_commerce/errors.py` is the new canonical home for the class. checkout.py, checkout_compute_first.py, identity/policy.py, stripe_multichain/{pay_to_address,payment_intent}.py, payment/dispatch.py import directly from there. Top-level __init__.py re-exports for the public surface. The checkout module no longer re-exports the class — direct imports break the cycle that previously required lazy/local imports for the identity.policy → checkout dep edge. 3. build_verification_required_body(reason, message=?, agent_instructions=?, extra=?) Collapses the per-merchant identity_verification_required body mapping into one call. Same shape as the node helper. 4. (Already existing — preserved) Checkout auto-defaults `create_session_on_missing` from gate.api_key + gate.base_url + gate.context + gate.merchant_name when not supplied. CLAUDE.md updated for symmetry with node. Compliance-merchant example updated to use the helper. Tests cover the new throws + helper. ✓ 1383 tests pass, 95.08% coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 + CLAUDE.md | 6 ++- agentscore_commerce/__init__.py | 4 +- agentscore_commerce/checkout.py | 43 ++++++++----------- agentscore_commerce/checkout_compute_first.py | 2 +- agentscore_commerce/errors.py | 40 +++++++++++++++++ agentscore_commerce/identity/__init__.py | 3 +- agentscore_commerce/identity/_response.py | 33 ++++++++++++++ agentscore_commerce/identity/policy.py | 5 +-- agentscore_commerce/payment/dispatch.py | 22 ++++++++-- .../stripe_multichain/pay_to_address.py | 42 +++++++++++++++--- .../stripe_multichain/payment_intent.py | 12 +++++- examples/compliance_merchant.py | 16 ++++--- pyproject.toml | 2 +- tests/test_checkout_compute_first.py | 2 +- tests/test_pay_to_address.py | 28 +++++++++++- tests/test_payment_dispatch.py | 14 +++++- tests/test_policy.py | 10 ++--- tests/test_response.py | 42 ++++++++++++++++++ tests/test_stripe_multichain.py | 6 ++- uv.lock | 2 +- 21 files changed, 275 insertions(+), 61 deletions(-) create mode 100644 agentscore_commerce/errors.py diff --git a/.gitignore b/.gitignore index d904602..d620643 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ venv/ *.swo .DS_Store .coverage +coverage/ +htmlcov/ diff --git a/CLAUDE.md b/CLAUDE.md index 351e006..8596c86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,11 @@ Two identity types: wallet (`X-Wallet-Address`) and operator-token (`X-Operator- `DenialReason` codes (`missing_identity`, `identity_verification_required`, `token_expired`, `invalid_credential`, `wallet_signer_mismatch`, `wallet_auth_requires_wallet_signing`, `wallet_not_trusted`, `api_error`, `payment_required`) each carry a structured `agent_instructions` JSON block describing concrete recovery actions. See `agentscore_commerce/identity/_response.py` for the canned action copy. -`create_session_on_missing` auto-mints a verification session when no identity is present and returns 403 with `verify_url` + poll instructions. `get_signer_verdict(request)` (per-adapter) returns the cached `signer_match` + `signer_sanctions` verdicts the gate composed on its primary `/v1/assess` call (single round trip; merchants build a 403 with `build_signer_mismatch_body(result=verdict.signer_match)` when `kind != "pass"`). +`create_session_on_missing` auto-mints a verification session when no identity is present AND when `wallet_not_trusted` carries fixable reasons (`kyc_required` / `kyc_pending` / `kyc_failed`) — both paths rewrite the denial to `identity_verification_required` before reaching `on_denied`. When the merchant omits `create_session_on_missing` from `CheckoutGateConfig`, `Checkout` auto-defaults it from `gate.api_key` + `gate.base_url` + `gate.context` + `gate.merchant_name`. Merchants that need `on_before_session` side effects (e.g. pre-minting an order_id) supply their own config to override. + +`build_verification_required_body(reason, message=?, agent_instructions=?, extra=?)` — canonical body builder for the `identity_verification_required` denial. Spreads `verify_url` / `session_id` / `poll_secret` / `poll_url` / `agent_instructions` from the gate-minted reason into a 4xx envelope with merchant-specific message + optional extras. Saves the per-merchant mapping boilerplate. + +`get_signer_verdict(request)` (per-adapter) returns the cached `signer_match` + `signer_sanctions` verdicts the gate composed on its primary `/v1/assess` call (single round trip; merchants build a 403 with `build_signer_mismatch_body(result=verdict.signer_match)` when `kind != "pass"`). Captured wallets: `capture_wallet(...)` is fire-and-forget. Reads `operator_token` stashed during gating and POSTs to `/v1/credentials/wallets`. No-ops for wallet-authenticated requests. diff --git a/agentscore_commerce/__init__.py b/agentscore_commerce/__init__.py index c90b9a6..fdc8262 100644 --- a/agentscore_commerce/__init__.py +++ b/agentscore_commerce/__init__.py @@ -20,7 +20,6 @@ CheckoutRailSpec, CheckoutRequest, CheckoutResult, - CheckoutValidationError, DiscoveryProbeConfig, MppxComposeOutcome, PricingResult, @@ -49,6 +48,7 @@ compute_first_checkout, ) from agentscore_commerce.checkout_hooks import make_mppx_compose_hook +from agentscore_commerce.errors import CheckoutValidationError # Re-export the most commonly used helpers at the package root so consumers # don't have to remember which submodule each one lives in. Mirrors node's @@ -96,6 +96,7 @@ build_jwks_response, build_signer_mismatch_body, build_ucp_profile, + build_verification_required_body, create_default_on_denied, default_read_only_on_denied, denial_reason_status, @@ -230,6 +231,7 @@ "build_mppx_compose_rails", "build_signer_mismatch_body", "build_ucp_profile", + "build_verification_required_body", "compute_first_checkout", "create_default_on_denied", "create_quote_cache", diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index 6ade67b..d2bf1c3 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -83,6 +83,7 @@ from agentscore_commerce.challenge.pricing import PricingBlock, build_pricing_block from agentscore_commerce.challenge.respond_402 import Respond402Result, respond_402 from agentscore_commerce.challenge.validation_error import build_validation_error +from agentscore_commerce.errors import CheckoutValidationError from agentscore_commerce.payment.payment_header import has_mppx_header, has_x402_header from agentscore_commerce.payment.rail_spec import ( RecipientLike, @@ -134,31 +135,6 @@ def _spec_method_name(spec: CheckoutRailSpec) -> str: return "stripe/spt" # StripeRailSpec is the only remaining variant in CheckoutRailSpec. -class CheckoutValidationError(Exception): - """Raised from a :attr:`Checkout.pre_validate` hook to short-circuit with a 4xx. - - Checkout catches this and emits the canonical ``{error, next_steps}`` envelope - via :func:`build_validation_error` so merchants don't have to construct - ``JSONResponse`` themselves in the pre-validate path. - """ - - def __init__( - self, - *, - code: str, - message: str, - action: str = "fix_request", - status: int = 400, - extra: dict[str, Any] | None = None, - ) -> None: - super().__init__(message) - self.code = code - self.message = message - self.action = action - self.status = status - self.extra = extra - - @dataclass class CheckoutRequest: """Framework-neutral HTTP request input to :meth:`Checkout.handle`. @@ -1801,7 +1777,22 @@ async def _emit_402( if ctx.pricing is None: msg = "Checkout._emit_402: pricing not computed" raise RuntimeError(msg) - await self._resolve_recipients(ctx) + try: + await self._resolve_recipients(ctx) + except CheckoutValidationError as err: + return CheckoutResult( + status=err.status, + body=build_validation_error( + code=err.code, + message=err.message, + next_steps={"action": err.action, "user_message": err.message}, + extra=err.extra, + ), + headers={}, + reference_id=ctx.reference_id, + settled=False, + settle_phase="mint_recipients_failed", + ) emit_rails = _apply_recipient_overrides(self.rails, ctx.recipients) accepted = await build_accepted_methods( diff --git a/agentscore_commerce/checkout_compute_first.py b/agentscore_commerce/checkout_compute_first.py index 0e0d97c..3a9f960 100644 --- a/agentscore_commerce/checkout_compute_first.py +++ b/agentscore_commerce/checkout_compute_first.py @@ -46,8 +46,8 @@ build_pricing_block, first_encounter_agent_memory, ) -from agentscore_commerce.checkout import CheckoutValidationError from agentscore_commerce.discovery import build_success_next_steps +from agentscore_commerce.errors import CheckoutValidationError from agentscore_commerce.payment.amounts import format_usd_cents from agentscore_commerce.payment.payment_header import has_mppx_header, has_x402_header from agentscore_commerce.payment.rail_spec import ( diff --git a/agentscore_commerce/errors.py b/agentscore_commerce/errors.py new file mode 100644 index 0000000..323111e --- /dev/null +++ b/agentscore_commerce/errors.py @@ -0,0 +1,40 @@ +"""Cross-module typed errors. + +Lives in its own module so payment / stripe_multichain helpers can throw +``CheckoutValidationError`` without importing ``agentscore_commerce.checkout`` +(which itself imports from ``agentscore_commerce.payment`` and would deadlock +at startup). + +Re-exported from :mod:`agentscore_commerce.checkout` to preserve the public +import path that consumers use today. +""" + +from __future__ import annotations + +from typing import Any + + +class CheckoutValidationError(Exception): + """Raised to short-circuit a Checkout flow with a 4xx/5xx envelope. + + Caught at request-flow boundaries (e.g. ``Checkout.pre_validate``, + recipient minting, settlement dispatch); the framework emits the canonical + ``{error, next_steps}`` body via :func:`build_validation_error` so + merchants don't construct framework Response objects themselves. + """ + + def __init__( + self, + *, + code: str, + message: str, + action: str = "fix_request", + status: int = 400, + extra: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.action = action + self.status = status + self.extra = extra diff --git a/agentscore_commerce/identity/__init__.py b/agentscore_commerce/identity/__init__.py index 003f67a..7d9b14f 100644 --- a/agentscore_commerce/identity/__init__.py +++ b/agentscore_commerce/identity/__init__.py @@ -10,7 +10,7 @@ is_fixable_denial, verification_agent_instructions, ) -from agentscore_commerce.identity._response import denial_reason_to_body +from agentscore_commerce.identity._response import build_verification_required_body, denial_reason_to_body from agentscore_commerce.identity.a2a import ( A2A_DEFAULT_TRANSPORT, A2A_PROTOCOL_VERSION, @@ -152,6 +152,7 @@ def _load_asgi_middleware() -> tuple[Any, Any, Any]: "build_jwks_response", "build_signer_mismatch_body", "build_ucp_profile", + "build_verification_required_body", "create_default_on_denied", "default_read_only_on_denied", "denial_reason_status", diff --git a/agentscore_commerce/identity/_response.py b/agentscore_commerce/identity/_response.py index 3ee15b4..2993d17 100644 --- a/agentscore_commerce/identity/_response.py +++ b/agentscore_commerce/identity/_response.py @@ -315,6 +315,39 @@ def build_missing_identity_reason() -> DenialReason: } +def build_verification_required_body( + reason: DenialReason, + *, + message: str | None = None, + agent_instructions: str | None = None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build the canonical 4xx body for ``identity_verification_required``. + + Every merchant maps the gate's auto-minted session fields (``verify_url``, + ``session_id``, ``poll_secret``, ``poll_url``, ``agent_instructions``) into + their own envelope with a merchant-specific message + error code. This + collapses that mapping into one call. + + Goods merchants that surface an ``order_id`` (or similar) from + ``CreateSessionOnMissing.on_before_session`` get it for free via + ``denial_reason_to_body``'s ``reason.extra`` passthrough — but can also + pass ``extra=`` for fallbacks (e.g. when invoked outside the auto-mint + path and order_id needs to come from the validated context). + """ + body = denial_reason_to_body(reason) + body["error"] = { + "code": "operator_verification_required", + "message": message or "Identity verification is required.", + } + if agent_instructions is not None: + body["agent_instructions"] = agent_instructions + if extra: + for k, v in extra.items(): + body[k] = v + return body + + def denial_reason_to_body(reason: DenialReason) -> dict[str, Any]: """Marshal a DenialReason dataclass into a flat dict suitable for the 403 JSON body. diff --git a/agentscore_commerce/identity/policy.py b/agentscore_commerce/identity/policy.py index f367b5f..b32b674 100644 --- a/agentscore_commerce/identity/policy.py +++ b/agentscore_commerce/identity/policy.py @@ -30,6 +30,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal, TypedDict +from agentscore_commerce.errors import CheckoutValidationError + if TYPE_CHECKING: from collections.abc import Mapping @@ -203,9 +205,6 @@ def validate_shipping_against_policy( 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( diff --git a/agentscore_commerce/payment/dispatch.py b/agentscore_commerce/payment/dispatch.py index 710602f..00904ef 100644 --- a/agentscore_commerce/payment/dispatch.py +++ b/agentscore_commerce/payment/dispatch.py @@ -11,6 +11,7 @@ from collections.abc import Awaitable, Callable, Mapping from typing import Any, Literal, TypeVar, cast +from agentscore_commerce.errors import CheckoutValidationError from agentscore_commerce.payment.network_kind import is_evm_network, is_solana_network T = TypeVar("T") @@ -55,14 +56,29 @@ async def dispatch_settlement_by_network( network = payload.accepted["network"] if isinstance(payload.accepted, dict) else payload.accepted.network if is_evm_network(network): if evm is None: - raise ValueError(f"No EVM settlement handler registered (network: {network})") + raise CheckoutValidationError( + code="payment_provider_unavailable", + message=f"No EVM settlement handler registered (network: {network})", + action="retry_later", + status=503, + ) result = evm(payload) elif is_solana_network(network): if svm is None: - raise ValueError(f"No Solana settlement handler registered (network: {network})") + raise CheckoutValidationError( + code="payment_provider_unavailable", + message=f"No Solana settlement handler registered (network: {network})", + action="retry_later", + status=503, + ) result = svm(payload) else: - raise ValueError(f"Unrecognized network in settlement payload: {network}") + raise CheckoutValidationError( + code="payment_provider_unavailable", + message=f"Unrecognized network in settlement payload: {network}", + action="retry_later", + status=503, + ) if inspect.isawaitable(result): return await cast("Awaitable[T]", result) return cast("T", result) diff --git a/agentscore_commerce/stripe_multichain/pay_to_address.py b/agentscore_commerce/stripe_multichain/pay_to_address.py index 7c3302c..ca40b2c 100644 --- a/agentscore_commerce/stripe_multichain/pay_to_address.py +++ b/agentscore_commerce/stripe_multichain/pay_to_address.py @@ -21,6 +21,7 @@ import asyncio from typing import TYPE_CHECKING, Any +from agentscore_commerce.errors import CheckoutValidationError from agentscore_commerce.stripe_multichain.payment_intent import ( create_multichain_payment_intent, ) @@ -59,16 +60,36 @@ async def create_pay_to_address_from_stripe_pi( from mpp import Credential # type: ignore[import-untyped] if authorization_header.startswith("Payment "): - credential = Credential.from_authorization(authorization_header) + try: + credential = Credential.from_authorization(authorization_header) + except Exception as err: + raise CheckoutValidationError( + code="invalid_credential", + message="The Authorization: Payment header is not a valid MPP credential.", + action="retry_without_credential", + status=401, + ) from err method = getattr(credential.challenge, "method", None) if method in ("tempo", "solana"): recipient = getattr(credential.challenge.request, "recipient", None) if not isinstance(recipient, str) or not recipient: - msg = "MPP credential challenge missing recipient field" - raise ValueError(msg) + raise CheckoutValidationError( + code="invalid_credential", + message="The MPP credential is missing its recipient field.", + action="retry_without_credential", + status=401, + ) if not await _maybe_await(pi_cache.has_address(recipient)): - msg = "Invalid payTo address: not found in cache or expired" - raise ValueError(msg) + raise CheckoutValidationError( + code="invalid_credential", + message=( + "The signed-against payTo recipient is not in this merchant's cache " + "(unknown or expired). Retry without the Authorization: Payment header " + "to receive a fresh 402 challenge." + ), + action="retry_without_credential", + status=401, + ) return recipient idempotency_key = f"pi-{order_id}-{amount_cents}" if order_id else None @@ -91,8 +112,15 @@ async def create_pay_to_address_from_stripe_pi( or result.deposit_addresses.get("tempo") ) if not pay_to: - msg = "Failed to resolve pay_to address from Stripe PaymentIntent" - raise RuntimeError(msg) + raise CheckoutValidationError( + code="payment_provider_unavailable", + message=( + "Stripe returned deposit addresses but none matched the requested network (tempo / base / solana). " + "The account may have only a subset of multichain networks enabled." + ), + action="retry_later", + status=503, + ) return pay_to diff --git a/agentscore_commerce/stripe_multichain/payment_intent.py b/agentscore_commerce/stripe_multichain/payment_intent.py index 6e3212b..1750aa7 100644 --- a/agentscore_commerce/stripe_multichain/payment_intent.py +++ b/agentscore_commerce/stripe_multichain/payment_intent.py @@ -7,6 +7,8 @@ from dataclasses import dataclass from typing import Any, Protocol +from agentscore_commerce.errors import CheckoutValidationError + class StripePaymentIntentsAPI(Protocol): def create(self, params: dict[str, Any], idempotency_key: str | None = None) -> Any: ... @@ -79,7 +81,15 @@ def create_multichain_payment_intent( deposit_addresses[network] = addr if not deposit_addresses: - raise RuntimeError("No deposit addresses returned from Stripe PaymentIntent") + raise CheckoutValidationError( + code="payment_provider_unavailable", + message=( + "Stripe returned no crypto deposit addresses for this PaymentIntent. " + "The account may not be enrolled in the Stablecoins and Crypto preview, or the feature was revoked." + ), + action="retry_later", + status=503, + ) pi_id = getattr(pi, "id", None) or (pi.get("id") if isinstance(pi, dict) else None) if not isinstance(pi_id, str): diff --git a/examples/compliance_merchant.py b/examples/compliance_merchant.py index 3e09a83..7211ab6 100644 --- a/examples/compliance_merchant.py +++ b/examples/compliance_merchant.py @@ -47,6 +47,7 @@ SettleOutcome, TempoRailSpec, build_contact_support_next_steps, + build_verification_required_body, denial_reason_status, denial_reason_to_body, is_fixable_denial, @@ -85,12 +86,17 @@ async def _on_denied(_ctx: Any, reason: DenialReason) -> dict[str, Any] | None: body["error"] = {"code": "identity_required", "message": "Identity verification is required for this purchase."} return {"status": 403, "body": body} - # identity_verification_required → gate auto-minted a session. Overlay - # vendor-specific agent_instructions on top of the commerce body. + # identity_verification_required → gate auto-minted a session. Use the + # canonical body builder + overlay vendor-specific agent_instructions. if reason.code == "identity_verification_required": - body = denial_reason_to_body(reason) - body["agent_instructions"] = VERIFICATION_INSTRUCTIONS - return {"status": 403, "body": body} + return { + "status": 403, + "body": build_verification_required_body( + reason, + message="Identity verification is required for this purchase.", + agent_instructions=VERIFICATION_INSTRUCTIONS, + ), + } # wallet_not_trusted = UNFIXABLE compliance fail (sanctions / age / # jurisdiction_restricted). The gate auto-routes fixable reasons upstream; diff --git a/pyproject.toml b/pyproject.toml index ab0357e..6700dd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "2.1.0" +version = "2.1.1" description = "Agent commerce SDK for Python — identity middleware (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce." readme = "README.md" license = "MIT" diff --git a/tests/test_checkout_compute_first.py b/tests/test_checkout_compute_first.py index f91154b..583de7c 100644 --- a/tests/test_checkout_compute_first.py +++ b/tests/test_checkout_compute_first.py @@ -56,7 +56,7 @@ async def test_zero_result_fast_path_returns_200_no_charge() -> None: @pytest.mark.asyncio async def test_validate_input_raises_returns_4xx_envelope() -> None: - from agentscore_commerce.checkout import CheckoutValidationError + from agentscore_commerce.errors import CheckoutValidationError def _validate(body: dict[str, Any]) -> None: if "q" not in body: diff --git a/tests/test_pay_to_address.py b/tests/test_pay_to_address.py index 3b8339e..0f96424 100644 --- a/tests/test_pay_to_address.py +++ b/tests/test_pay_to_address.py @@ -111,14 +111,40 @@ async def test_reuses_credential_recipient_when_cached() -> None: @pytest.mark.asyncio async def test_raises_when_credential_recipient_not_in_cache() -> None: + from agentscore_commerce.errors import CheckoutValidationError + cache = FakePiCache(has_address_result=False) - with patch("mpp.Credential", FakeCredential), pytest.raises(ValueError, match="not found in cache"): + with patch("mpp.Credential", FakeCredential), pytest.raises(CheckoutValidationError) as exc: await create_pay_to_address_from_stripe_pi( authorization_header="Payment tempo:0xUNKNOWN", amount_cents=100, stripe=_fake_stripe({}), pi_cache=cache, # type: ignore[arg-type] ) + assert exc.value.code == "invalid_credential" + assert exc.value.status == 401 + + +@pytest.mark.asyncio +async def test_raises_when_authorization_header_is_malformed() -> None: + from agentscore_commerce.errors import CheckoutValidationError + + class _ThrowingCredential: + @staticmethod + def from_authorization(_: str) -> object: + msg = "Invalid base64url or JSON." + raise ValueError(msg) + + cache = FakePiCache(has_address_result=True) + with patch("mpp.Credential", _ThrowingCredential), pytest.raises(CheckoutValidationError) as exc: + await create_pay_to_address_from_stripe_pi( + authorization_header="Payment fake.jwt.bogus", + amount_cents=100, + stripe=_fake_stripe({}), + pi_cache=cache, # type: ignore[arg-type] + ) + assert exc.value.code == "invalid_credential" + assert exc.value.status == 401 @pytest.mark.asyncio diff --git a/tests/test_payment_dispatch.py b/tests/test_payment_dispatch.py index 0649dfe..eff2741 100644 --- a/tests/test_payment_dispatch.py +++ b/tests/test_payment_dispatch.py @@ -23,15 +23,25 @@ async def test_dispatches_to_svm_for_solana(): async def test_raises_when_no_handler_registered(): + from agentscore_commerce.errors import CheckoutValidationError + p = _Payload(accepted={"network": "eip155:8453"}) - with pytest.raises(ValueError, match="No EVM"): + with pytest.raises(CheckoutValidationError) as exc: await dispatch_settlement_by_network(p, svm=lambda _: "x") + assert exc.value.code == "payment_provider_unavailable" + assert exc.value.status == 503 + assert "No EVM" in exc.value.message async def test_raises_for_unrecognized_network(): + from agentscore_commerce.errors import CheckoutValidationError + p = _Payload(accepted={"network": "cosmos:foo"}) - with pytest.raises(ValueError, match="Unrecognized"): + with pytest.raises(CheckoutValidationError) as exc: await dispatch_settlement_by_network(p, evm=lambda _: "x", svm=lambda _: "x") + assert exc.value.code == "payment_provider_unavailable" + assert exc.value.status == 503 + assert "Unrecognized" in exc.value.message async def test_awaits_async_handler(): diff --git a/tests/test_policy.py b/tests/test_policy.py index 8a422b9..2244c04 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -175,7 +175,7 @@ def test_validate_shipping_no_op_when_allowlist_empty() -> None: def test_validate_shipping_raises_on_disallowed_country() -> None: - from agentscore_commerce.checkout import CheckoutValidationError + from agentscore_commerce.errors import CheckoutValidationError with pytest.raises(CheckoutValidationError) as exc: validate_shipping_against_policy(country="JP", state="", policy={"allowed_shipping_countries": ["US"]}) @@ -185,7 +185,7 @@ def test_validate_shipping_raises_on_disallowed_country() -> None: def test_validate_shipping_raises_on_disallowed_state() -> None: - from agentscore_commerce.checkout import CheckoutValidationError + from agentscore_commerce.errors import CheckoutValidationError policy = {"allowed_shipping_countries": ["US"], "allowed_shipping_states": ["CA", "NY"]} with pytest.raises(CheckoutValidationError) as exc: @@ -195,7 +195,7 @@ def test_validate_shipping_raises_on_disallowed_state() -> None: def test_validate_shipping_product_name_appears_in_message() -> None: - from agentscore_commerce.checkout import CheckoutValidationError + from agentscore_commerce.errors import CheckoutValidationError with pytest.raises(CheckoutValidationError) as exc: validate_shipping_against_policy( @@ -208,7 +208,7 @@ def test_validate_shipping_product_name_appears_in_message() -> None: def test_validate_shipping_custom_messages_override_defaults() -> None: - from agentscore_commerce.checkout import CheckoutValidationError + from agentscore_commerce.errors import CheckoutValidationError with pytest.raises(CheckoutValidationError) as exc_country: validate_shipping_against_policy( @@ -231,7 +231,7 @@ def test_validate_shipping_custom_messages_override_defaults() -> None: def test_validate_shipping_custom_code_and_action() -> None: - from agentscore_commerce.checkout import CheckoutValidationError + from agentscore_commerce.errors import CheckoutValidationError with pytest.raises(CheckoutValidationError) as exc: validate_shipping_against_policy( diff --git a/tests/test_response.py b/tests/test_response.py index 29be385..af2ff4c 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -85,3 +85,45 @@ def test_api_error_with_quota_instructions_overrides_retry_default() -> None: instructions = json.loads(body["agent_instructions"]) assert instructions["action"] == "contact_merchant" assert "merchant-side issue" in instructions["steps"][0] + + +def test_build_verification_required_body_default_message() -> None: + from agentscore_commerce.identity._response import build_verification_required_body + + body = build_verification_required_body( + DenialReason( + code="identity_verification_required", + verify_url="https://x.example/v", + session_id="sess_x", + poll_secret="poll_x", + poll_url="https://x.example/p", + ), + ) + assert body["error"] == { + "code": "operator_verification_required", + "message": "Identity verification is required.", + } + assert body["verify_url"] == "https://x.example/v" + assert body["session_id"] == "sess_x" + assert body["poll_secret"] == "poll_x" + assert body["poll_url"] == "https://x.example/p" + + +def test_build_verification_required_body_merchant_overrides() -> None: + from agentscore_commerce.identity._response import build_verification_required_body + + body = build_verification_required_body( + DenialReason( + code="identity_verification_required", + verify_url="https://x.example/v", + session_id="sess_y", + poll_secret="poll_y", + poll_url="https://x.example/p", + ), + message="Identity verification is required to purchase wine.", + agent_instructions='{"action":"merchant_specific"}', + extra={"order_id": "ord_1"}, + ) + assert body["error"]["message"] == "Identity verification is required to purchase wine." + assert body["agent_instructions"] == '{"action":"merchant_specific"}' + assert body["order_id"] == "ord_1" diff --git a/tests/test_stripe_multichain.py b/tests/test_stripe_multichain.py index 1a08ba5..3465c07 100644 --- a/tests/test_stripe_multichain.py +++ b/tests/test_stripe_multichain.py @@ -47,9 +47,13 @@ def test_create_multichain_payment_intent_extracts_addresses(): def test_create_multichain_payment_intent_raises_when_no_addresses(): + from agentscore_commerce.errors import CheckoutValidationError + response = {"id": "pi_x", "next_action": None} - with pytest.raises(RuntimeError, match="No deposit addresses"): + with pytest.raises(CheckoutValidationError) as exc: create_multichain_payment_intent(stripe=_FakeClient(_FakeAPI(response)), amount=100) + assert exc.value.code == "payment_provider_unavailable" + assert exc.value.status == 503 def test_create_multichain_payment_intent_forwards_metadata(): diff --git a/uv.lock b/uv.lock index 2ff420d..4a401f5 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [[package]] name = "agentscore-commerce" -version = "2.1.0" +version = "2.1.1" source = { editable = "." } dependencies = [ { name = "agentscore-py" }, From 3df0378b9f51a9ada7f5fa5dc27790e78176892f Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 19 May 2026 08:43:14 -0700 Subject: [PATCH 2/6] feat(payment): auto-drop stripe/charge rail below $0.50 minimum build_mppx_compose_rails now drops the stripe/charge intent (with a one-time logging.warning) when amount_usd < 0.50. Stripe's fixed ~$0.30 fee makes sub-50-cent charges unprofitable - a $0.11 PI nets -$0.19 after fees; many accounts also reject PI creation under the floor with amount_too_small. Callers can pass include_stripe=False explicitly to silence the warning. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- README.md | 2 +- agentscore_commerce/payment/compose_rails.py | 34 +++++++++++++++++++- examples/compute_first_merchant.py | 6 +++- tests/test_compose_rails.py | 14 ++++++++ 5 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8596c86..54e49e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Every helper is extracted from a real consumer, not speculated. | `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 `compute_first_checkout` — variable-cost pay-per-result helper (compute-first + exact-x402). Scope is exact-mode rails only (x402-exact Base, tempo/charge, solana/charge, Stripe SPT); does NOT use x402-upto (Permit2) or Settlement-Overrides — variable cost is captured by running the work pre-settle and emitting a 402 at the exact computed price. `create_quote_cache` — content-hash quote cache used by the compute-first helper (in-memory by default; pass `redis_url` for distributed deployments). `create_default_on_denied` — canonical `on_denied(reason)` factory matching `Checkout`'s gate hook (handles `wallet_signer_mismatch`, `wallet_not_trusted` unfixable fallback, `payment_required`, `token_expired`/`invalid_credential`/`api_error`); merchants pass `merchant_name` + `support_email` and override `wallet_not_trusted_message` / `payment_required_message` / `support_context` for vendor-specific copy. `has_payment_header` — discriminator that splits discovery legs (no payment credential → 402) from settle legs (`payment-signature` / `x-payment` / `Authorization: Payment `); `has_x402_header` / `has_mppx_header` — granular dispatch helpers (x402 vs MPP credential present) for routes that branch on rail. `default_read_only_on_denied(reason)` — canonical `on_denied` for read-only resource gates (`GET /orders/:id`): collapses every denial to 401 `unauthorized` + `Cache-Control: no-store` while still spreading `denial_reason_to_body` so `agent_instructions` / `verify_url` ride through. Returns a `DefaultOnDeniedResult(body, status, headers)` dataclass; FastAPI / Flask / aiohttp / Sanic `on_denied` callbacks accept an optional 3-tuple `(body, status, headers)` — convert with `lambda req, reason: (r := default_read_only_on_denied(reason), (r.body, r.status, r.headers or {}))[1]` or a named wrapper. Django + ASGI middleware adapters return Response objects directly; construct `JsonResponse(r.body, status=r.status, headers=r.headers)` / `JSONResponse(content=r.body, status_code=r.status, headers=r.headers)`. `extract_owner_scope(headers) -> OwnerScope` — pull canonical owner identity from `X-Wallet-Address` / `X-Operator-Token` with safe token hashing; pair with a wallet-or-token-scoped resource query so plaintext tokens never leave the request. 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). Each adapter exports a conditional variant that wraps the gate so it fires only on settle legs (anonymous discovery flows through and gets a 402 with all rails): FastAPI / ASGI expose `ConditionalAgentScoreGate`, Django exposes `ConditionalAgentScoreMiddleware`, Flask + Sanic expose `conditional_agentscore_gate(app, ...)`, aiohttp exposes `conditional_agentscore_gate_middleware(...)`. Adapters export ONLY framework-specific surface (gate classes / fns, accessors, `capture_wallet`); shared helpers like `has_payment_header` / `denial_reason_to_body` import from their canonical home (`agentscore_commerce.payment` and `agentscore_commerce.identity` respectively). The existing `agentscore_gate(app, ...)` and `AgentScoreGate` accept an optional `condition=` callable for inline gating. | | `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), `build_default_checkout_rails(tempo=, x402_base=, solana_mpp=, stripe=)` (canonical 4-rail `rails` dict factory: merchants pass per-rail overrides instead of redeclaring the recipient sentinel + network/chain_id/token boilerplate. When a caller flips `network` without pinning `token` / `chain_id`, the underlying dataclass derives them from the network: Base Sepolia → Sepolia USDC + chain_id 84532, Solana devnet → devnet USDC mint. Explicit overrides always win. Solana's `network` field accepts both CAIP-2 (`solana:5eykt4UsFv8…` / `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) AND the raw `@solana/mpp` form (`mainnet-beta` / `devnet` / `localnet`)), `build_mppx_compose_rails(amount_usd=, tempo_recipient=, solana_recipient=, ...)` (per-call intent factory replacing the hand-rolled `[("tempo/charge", {...}), ("solana/charge", {...}), ("stripe/charge", {...})]` list; auto-handles USD→atomic conversion for Solana), `process_x402_settle` (verify+settle in one call), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6`), `is_evm_network`/`is_solana_network` (CAIP-2 discriminators that hide the `startswith("eip155:")` / `startswith("solana:")` prefix matching), `has_payment_header` (settle-leg vs discovery-leg discriminator), `parse_did_pkh_address` (parses ``did:pkh:::`` into a `PaymentSigner`), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header | +| `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), `build_default_checkout_rails(tempo=, x402_base=, solana_mpp=, stripe=)` (canonical 4-rail `rails` dict factory: merchants pass per-rail overrides instead of redeclaring the recipient sentinel + network/chain_id/token boilerplate. When a caller flips `network` without pinning `token` / `chain_id`, the underlying dataclass derives them from the network: Base Sepolia → Sepolia USDC + chain_id 84532, Solana devnet → devnet USDC mint. Explicit overrides always win. Solana's `network` field accepts both CAIP-2 (`solana:5eykt4UsFv8…` / `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) AND the raw `@solana/mpp` form (`mainnet-beta` / `devnet` / `localnet`)), `build_mppx_compose_rails(amount_usd=, tempo_recipient=, solana_recipient=, ...)` (per-call intent factory replacing the hand-rolled `[("tempo/charge", {...}), ("solana/charge", {...}), ("stripe/charge", {...})]` list; auto-handles USD→atomic conversion for Solana; auto-drops the `stripe/charge` rail with a one-time `logging.warning` when `amount_usd < 0.50` since Stripe's fixed ~$0.30 fee makes sub-50-cent charges unprofitable — many Stripe accounts also reject PI creation below the floor with `amount_too_small`; sub-50-cent APIs pass `include_stripe=False` explicitly to silence the warning), `process_x402_settle` (verify+settle in one call), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6`), `is_evm_network`/`is_solana_network` (CAIP-2 discriminators that hide the `startswith("eip155:")` / `startswith("solana:")` prefix matching), `has_payment_header` (settle-leg vs discovery-leg discriminator), `parse_did_pkh_address` (parses ``did:pkh:::`` into a `PaymentSigner`), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header | | `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), `create_pay_to_address_from_stripe_pi(authorization_header=, amount_cents=, stripe=, pi_cache=, networks=, metadata=, order_id=, preferred_network=)` — one-call per-order payTo resolver matching `Checkout.mint_recipients`: on the settle leg, reuses the buyer's signed-against payTo from the MPP credential (after `pi_cache.has_address` check); on the discovery leg, mints a fresh PI via `create_multichain_payment_intent` and caches the addresses + PI mapping. Testnet simulator (`simulate_crypto_deposit`, `simulate_deposit_if_test_mode`), `simulate_deposit_for_outcome(outcome=, deposit_address=, get_payment_intent_id=, stripe_secret_key=, stripe_version=)` (dispatches the simulator based on a Checkout / compute_first_checkout settle outcome; replaces the per-merchant rail-switch + thin `simulate_deposit_if_testnet(addr, network)` wrapper), `network_for_outcome` (outcome → simulator network arg, handles both Checkout-shaped `rail_key` and compute-first-shaped `mpp_method`, accepts bare scheme names AND `/charge` forms), `create_pi_cache`, `create_mppx_stripe` | diff --git a/README.md b/README.md index eded5ca..d35fb7c 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ For **variable-cost pay-per-result** endpoints (per-result search, per-token LLM For the `on_denied` hook on Checkout's gate config, `create_default_on_denied(merchant_name=, support_email=, ...)` returns the canonical denial callback that handles `wallet_signer_mismatch` / `wallet_not_trusted` unfixable fallback / `payment_required` / `token_expired` / `invalid_credential` / `api_error`. Merchants override `wallet_not_trusted_message` / `payment_required_message` / `support_context` for vendor-specific copy and keep their own merchant-specific branches (e.g. wine merchants add a fixable-denial-with-session branch on top). -`build_default_checkout_rails(tempo=, x402_base=, solana_mpp=, stripe=)` builds the canonical four-rail `rails` dict so merchants pass per-rail overrides instead of redeclaring the recipient sentinel + network/chain_id/token boilerplate. Flipping `network` alone is enough: Base Sepolia derives Sepolia USDC + chain_id 84532, Solana devnet derives the devnet USDC mint. Solana's `network` field accepts both CAIP-2 (`solana:5eykt4UsFv8…` / `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) and the raw `@solana/mpp` form (`mainnet-beta` / `devnet` / `localnet`). `build_mppx_compose_rails(amount_usd=, tempo_recipient=, solana_recipient=, ...)` builds the per-call mppx intent list. `simulate_deposit_for_outcome(outcome=, deposit_address=, get_payment_intent_id=, stripe_secret_key=)` dispatches the Stripe testnet simulator from `on_settled` based on the rail family (no per-merchant rail switch needed). +`build_default_checkout_rails(tempo=, x402_base=, solana_mpp=, stripe=)` builds the canonical four-rail `rails` dict so merchants pass per-rail overrides instead of redeclaring the recipient sentinel + network/chain_id/token boilerplate. Flipping `network` alone is enough: Base Sepolia derives Sepolia USDC + chain_id 84532, Solana devnet derives the devnet USDC mint. Solana's `network` field accepts both CAIP-2 (`solana:5eykt4UsFv8…` / `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) and the raw `@solana/mpp` form (`mainnet-beta` / `devnet` / `localnet`). `build_mppx_compose_rails(amount_usd=, tempo_recipient=, solana_recipient=, ...)` builds the per-call mppx intent list. The helper auto-drops `stripe/charge` (with a one-time `logging.warning`) when `amount_usd < 0.50` since Stripe's fixed ~$0.30 fee makes sub-50-cent charges unprofitable; sub-50-cent APIs pass `include_stripe=False` explicitly to silence the warning. `simulate_deposit_for_outcome(outcome=, deposit_address=, get_payment_intent_id=, stripe_secret_key=)` dispatches the Stripe testnet simulator from `on_settled` based on the rail family (no per-merchant rail switch needed). ## Payment helpers diff --git a/agentscore_commerce/payment/compose_rails.py b/agentscore_commerce/payment/compose_rails.py index 4c5e52d..96758ac 100644 --- a/agentscore_commerce/payment/compose_rails.py +++ b/agentscore_commerce/payment/compose_rails.py @@ -11,12 +11,17 @@ from __future__ import annotations +import logging +from decimal import Decimal, InvalidOperation from typing import Any from agentscore_commerce.payment.amounts import usd_to_atomic from agentscore_commerce.payment.usdc import USDC _SOLANA_MAINNET_CAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" +_STRIPE_MIN_CHARGE_USD = Decimal("0.50") +_warned_stripe_below_minimum = False +_logger = logging.getLogger(__name__) def build_mppx_compose_rails( @@ -51,6 +56,16 @@ def build_mppx_compose_rails( include_stripe: Include the ``stripe/charge`` intent (Stripe SPT rail). Default ``True``. + Stripe's documented USD minimum is $0.50 because the fixed + processing fee (~$0.30) exceeds revenue below that — sub-50-cent + charges that DO go through still cost the merchant money (a + $0.11 PI nets -$0.19 after fees). Some Stripe accounts also + reject PI creation under the floor with ``amount_too_small``. + The helper auto-drops the rail (with a one-time + ``logging.warning``) when ``amount_usd < 0.50`` so sub-50-cent + APIs don't ship an unprofitable rail. Pass + ``include_stripe=False`` explicitly to suppress the warning. + Raises: ValueError: when Solana is requested but ``amount_usd`` can't convert to atomic — merchants should catch and return a 402 to drop the rail @@ -84,5 +99,22 @@ def build_mppx_compose_rails( ) ) if include_stripe: - rails.append(("stripe/charge", {"amount": amount_usd, "currency": "usd", "decimals": 2})) + try: + amount_decimal = Decimal(amount_usd) + except (InvalidOperation, TypeError, ValueError): + amount_decimal = None + if amount_decimal is not None and amount_decimal < _STRIPE_MIN_CHARGE_USD: + global _warned_stripe_below_minimum + if not _warned_stripe_below_minimum: + _warned_stripe_below_minimum = True + _logger.warning( + "[build_mppx_compose_rails] Dropping stripe/charge rail: amount_usd=%s is below " + "Stripe's $%s USD minimum. Stripe's fixed ~$0.30 fee makes sub-50-cent charges " + "unprofitable (and many accounts reject PI creation with amount_too_small below " + "this floor). Pass include_stripe=False to suppress this warning.", + amount_usd, + _STRIPE_MIN_CHARGE_USD, + ) + else: + rails.append(("stripe/charge", {"amount": amount_usd, "currency": "usd", "decimals": 2})) return rails diff --git a/examples/compute_first_merchant.py b/examples/compute_first_merchant.py index 64b1230..1f9fb1f 100644 --- a/examples/compute_first_merchant.py +++ b/examples/compute_first_merchant.py @@ -24,7 +24,11 @@ mppx intents at the exact cached price — see ``multi_rail_merchant.py`` for the fixed-price MPP compose pattern; the compute-first variant is structurally identical except the helper passes the cached price + -recipients into your callback. +recipients into your callback. Stripe SPT requires the computed price +to be at least $0.50 USD — below that Stripe's fixed ~$0.30 fee makes +the charge unprofitable, so ``build_mppx_compose_rails`` auto-drops the +stripe rail and sub-50-cent pay-per-result APIs ship Tempo + x402 + +Solana only. Peer deps:: diff --git a/tests/test_compose_rails.py b/tests/test_compose_rails.py index dc82066..478684d 100644 --- a/tests/test_compose_rails.py +++ b/tests/test_compose_rails.py @@ -54,3 +54,17 @@ def test_raises_when_amount_unparseable_with_solana_rail() -> None: tempo_recipient="0xabc", solana_recipient="SolAddr", ) + + +def test_auto_drops_stripe_when_amount_below_min(caplog: pytest.LogCaptureFixture) -> None: + import agentscore_commerce.payment.compose_rails as mod + + mod._warned_stripe_below_minimum = False # reset module-level warn-once flag + with caplog.at_level("WARNING"): + rails = build_mppx_compose_rails(amount_usd="0.01", tempo_recipient="0xabc") + assert all(r[0] != "stripe/charge" for r in rails) + + +def test_keeps_stripe_at_50_cent_boundary() -> None: + rails = build_mppx_compose_rails(amount_usd="0.50", tempo_recipient="0xabc") + assert any(r[0] == "stripe/charge" for r in rails) From 07f8f63eb973598b539fc1373a010eaec5166c38 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 19 May 2026 09:01:38 -0700 Subject: [PATCH 3/6] feat(payment): drop stripe/charge from emit_402 when amount under $0.50 The compose-time auto-drop in build_mppx_compose_rails landed last commit but the 402 body's accepted_methods + how_to_pay still came from the static build_default_checkout_rails config - so agents saw stripe offered even though there was no matching WWW-Authenticate challenge for it. Move STRIPE_MIN_CHARGE_USD into payment/constants.py and consume it from BOTH layers: - build_mppx_compose_rails (already did): drops the stripe intent from the compose list. - Checkout._emit_402 + compute_first_checkout._emit_402 (this commit): strip stripe from emit_rails before build_accepted_methods runs, so accepted_methods + how_to_pay never advertise a rail mppx won't accept. For variable-price merchants where one product is below $0.50 and others above, each cart now gets a consistent 402 - the rail appears/disappears with the cart total. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/checkout.py | 12 ++++++++++++ agentscore_commerce/checkout_compute_first.py | 7 ++++++- agentscore_commerce/payment/compose_rails.py | 6 +++--- agentscore_commerce/payment/constants.py | 16 ++++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 agentscore_commerce/payment/constants.py diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index d2bf1c3..18c7188 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -71,6 +71,7 @@ import uuid from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass, field +from decimal import Decimal from typing import Any, Literal, TypeAlias from agentscore_commerce._headers import normalize_headers_to_lowercase @@ -84,6 +85,7 @@ from agentscore_commerce.challenge.respond_402 import Respond402Result, respond_402 from agentscore_commerce.challenge.validation_error import build_validation_error from agentscore_commerce.errors import CheckoutValidationError +from agentscore_commerce.payment.constants import STRIPE_MIN_CHARGE_USD from agentscore_commerce.payment.payment_header import has_mppx_header, has_x402_header from agentscore_commerce.payment.rail_spec import ( RecipientLike, @@ -1795,6 +1797,16 @@ async def _emit_402( ) emit_rails = _apply_recipient_overrides(self.rails, ctx.recipients) + # Auto-drop stripe when priced below Stripe's $0.50 USD minimum so the + # emitted accepted_methods + how_to_pay stay consistent with what the + # mppx compose layer will actually accept (see build_mppx_compose_rails). + # Without this, the 402 body advertises a stripe rail that has no + # matching WWW-Authenticate challenge — agents see it offered but any + # SPT pay attempt fails. The compose-time auto-drop emits the + # user-facing warn; here we just strip the slot from the discovery body. + if Decimal(str(ctx.pricing.amount_usd)) < STRIPE_MIN_CHARGE_USD and "stripe" in emit_rails: + emit_rails = {k: v for k, v in emit_rails.items() if k != "stripe"} + accepted = await build_accepted_methods( tempo=_pick(emit_rails, "tempo", TempoRailSpec), x402_base=_pick(emit_rails, "x402_base", X402BaseRailSpec), diff --git a/agentscore_commerce/checkout_compute_first.py b/agentscore_commerce/checkout_compute_first.py index 3a9f960..b85929e 100644 --- a/agentscore_commerce/checkout_compute_first.py +++ b/agentscore_commerce/checkout_compute_first.py @@ -35,6 +35,7 @@ import uuid from dataclasses import dataclass, field from datetime import datetime, timezone +from decimal import Decimal from typing import TYPE_CHECKING, Any from agentscore_commerce._mppx_receipt import derive_mppx_receipt_method @@ -49,6 +50,7 @@ from agentscore_commerce.discovery import build_success_next_steps from agentscore_commerce.errors import CheckoutValidationError from agentscore_commerce.payment.amounts import format_usd_cents +from agentscore_commerce.payment.constants import STRIPE_MIN_CHARGE_USD from agentscore_commerce.payment.payment_header import has_mppx_header, has_x402_header from agentscore_commerce.payment.rail_spec import ( SolanaMppRailSpec, @@ -328,7 +330,10 @@ async def _emit_402( accepted_rails["tempo"] = _replace_recipient(self.rails.tempo, tempo_recipient) if solana_recipient and self.rails.solana_mpp is not None: accepted_rails["solana_mpp"] = _replace_recipient(self.rails.solana_mpp, solana_recipient) - if self.rails.stripe is not None: + # Auto-drop stripe when the computed price is below Stripe's $0.50 USD + # minimum so accepted_methods stays consistent with what build_mppx_compose_rails + # actually composes (see agentscore_commerce.payment.constants). + if self.rails.stripe is not None and Decimal(total_usd) >= STRIPE_MIN_CHARGE_USD: accepted_rails["stripe"] = self.rails.stripe accepted = await build_accepted_methods(**accepted_rails) diff --git a/agentscore_commerce/payment/compose_rails.py b/agentscore_commerce/payment/compose_rails.py index 96758ac..61f9a44 100644 --- a/agentscore_commerce/payment/compose_rails.py +++ b/agentscore_commerce/payment/compose_rails.py @@ -16,10 +16,10 @@ from typing import Any from agentscore_commerce.payment.amounts import usd_to_atomic +from agentscore_commerce.payment.constants import STRIPE_MIN_CHARGE_USD from agentscore_commerce.payment.usdc import USDC _SOLANA_MAINNET_CAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" -_STRIPE_MIN_CHARGE_USD = Decimal("0.50") _warned_stripe_below_minimum = False _logger = logging.getLogger(__name__) @@ -103,7 +103,7 @@ def build_mppx_compose_rails( amount_decimal = Decimal(amount_usd) except (InvalidOperation, TypeError, ValueError): amount_decimal = None - if amount_decimal is not None and amount_decimal < _STRIPE_MIN_CHARGE_USD: + if amount_decimal is not None and amount_decimal < STRIPE_MIN_CHARGE_USD: global _warned_stripe_below_minimum if not _warned_stripe_below_minimum: _warned_stripe_below_minimum = True @@ -113,7 +113,7 @@ def build_mppx_compose_rails( "unprofitable (and many accounts reject PI creation with amount_too_small below " "this floor). Pass include_stripe=False to suppress this warning.", amount_usd, - _STRIPE_MIN_CHARGE_USD, + STRIPE_MIN_CHARGE_USD, ) else: rails.append(("stripe/charge", {"amount": amount_usd, "currency": "usd", "decimals": 2})) diff --git a/agentscore_commerce/payment/constants.py b/agentscore_commerce/payment/constants.py new file mode 100644 index 0000000..a6857ed --- /dev/null +++ b/agentscore_commerce/payment/constants.py @@ -0,0 +1,16 @@ +"""Shared payment-layer constants.""" + +from __future__ import annotations + +from decimal import Decimal + +STRIPE_MIN_CHARGE_USD: Decimal = Decimal("0.50") +"""Stripe's documented USD minimum charge. + +Stripe's fixed ~$0.30 processing fee makes sub-50-cent charges unprofitable +(a $0.11 PI nets -$0.19 after fees); many accounts also reject PI creation +under the floor with ``amount_too_small``. The SDK auto-drops the +``stripe/charge`` rail from BOTH the 402 body's ``accepted_methods`` AND the +per-call mppx compose intents when the priced amount falls below this +threshold, so agents never see a rail they can't profitably use. +""" From f9ea8078dfa384b62da970003104dbe676c2e87e Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 19 May 2026 12:31:01 -0700 Subject: [PATCH 4/6] chore(deps): in-range bumps - lefthook 2.1.6 -> 2.1.8 uv sync --upgrade --all-extras --all-groups; no major bumps. Co-Authored-By: Claude Opus 4.7 (1M context) --- uv.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/uv.lock b/uv.lock index 4a401f5..8bf73ad 100644 --- a/uv.lock +++ b/uv.lock @@ -1644,15 +1644,15 @@ wheels = [ [[package]] name = "lefthook" -version = "2.1.6" +version = "2.1.8" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/1c/95addeea6b681f02cd44e40d8ce970973783f7b48081af88d3831c4f6da6/lefthook-2.1.6-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:6d4608d0bb9dbcf10d333132973941c21bc7cde31a328658611e2066eec26d7e", size = 5439518, upload-time = "2026-04-16T07:34:18.136Z" }, - { url = "https://files.pythonhosted.org/packages/cd/d4/2c645051bed898f1ad377e7c2e611e0f857502ca76d052db0f8333bb668d/lefthook-2.1.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa6395917dc510c2622f31b6d3992c5506ff2a7569cd9d321712ec29689229b3", size = 4964898, upload-time = "2026-04-16T07:34:13.497Z" }, - { url = "https://files.pythonhosted.org/packages/19/ea/15b0384c1227ad172af0c18f21f7e00c570ccce086af471ac32edc2cb507/lefthook-2.1.6-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:016295036dd1d94af7ea6604247f0c47102559fb12f97ffcfe7880e8d1ce7a1f", size = 4791370, upload-time = "2026-04-16T07:34:15.04Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c0/3d43c9e1a08fa79d53f0c6eb808fb0ae9becf29bc7bd89cd757470af040e/lefthook-2.1.6-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:6c00cd91d553e064a2f71c35d2497b4350d1efdf18dd8f2c7ce2b1d6e8e2e147", size = 5367784, upload-time = "2026-04-16T07:34:10.414Z" }, - { url = "https://files.pythonhosted.org/packages/e6/99/cad1e694989964a8a79b34fdd18e6642bc631e280c06a9341565b5861e3b/lefthook-2.1.6-py3-none-win_amd64.whl", hash = "sha256:e571c48a227f51e5b8aec117c550c6d2ad1f74034e87afd5b0c98cbef319ad3e", size = 5516604, upload-time = "2026-04-16T07:34:12.061Z" }, - { url = "https://files.pythonhosted.org/packages/85/e9/286657a2c7efbb8701380c4c8a08c54d7f58938a4610d660c463fa074ee3/lefthook-2.1.6-py3-none-win_arm64.whl", hash = "sha256:985b88d908067a6d48a0e89cbc20c18b6a68a3fc4d44387f8ac1c39b85f0f18b", size = 4867769, upload-time = "2026-04-16T07:34:16.757Z" }, + { url = "https://files.pythonhosted.org/packages/cf/80/229efed8dc32caac69867d176fd97ec51d8610943817717873aeb64741b4/lefthook-2.1.8-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:7748eed7cc59982cc4a6a8c6b8a414ea6317065118287d40fb3682fd106f022b", size = 5510974, upload-time = "2026-05-19T11:12:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f2/b994e7a1ce986c8d3278f9c239c583707ad1aebcff87b51705b96da63225/lefthook-2.1.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c3a597f112ad44a68e0fc8b62c235254dbf93297992a69bac3e9b7c42ab21ec2", size = 5032376, upload-time = "2026-05-19T11:13:03.339Z" }, + { url = "https://files.pythonhosted.org/packages/76/96/23a32760540332dc758574c5f2d0ef3d9880e60d27484a9c870e1fea3d24/lefthook-2.1.8-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:ccf0571131c247cf195d4fb362c30497f4e64e6a3a4a002697eccaff7396ac1d", size = 4860343, upload-time = "2026-05-19T11:13:07.455Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e2/35bd38db60c0ed1abc833275d03da7915bb2d5ede0c8308ad31b66ecf9a2/lefthook-2.1.8-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:3dbc5e2934e6d218037e2fd9106a91f9c4b7d36a4ce8cf1190cd50223b6bc30f", size = 5441925, upload-time = "2026-05-19T11:13:05.337Z" }, + { url = "https://files.pythonhosted.org/packages/39/24/1e3db44d8ded321781ed29539f6b4030c799df654326e375a25dc115aaad/lefthook-2.1.8-py3-none-win_amd64.whl", hash = "sha256:9107e326e6ac1d6313d6071ba51e37fede2968592c8ed2f532974a85497a3b4c", size = 5592443, upload-time = "2026-05-19T11:13:00.92Z" }, + { url = "https://files.pythonhosted.org/packages/0e/74/c4662e6131c55a93018664d4285d46bb0ecaa60f5f61d451227509d9a973/lefthook-2.1.8-py3-none-win_arm64.whl", hash = "sha256:1bb39299c6758459b9c06c1bfe42b52688d4cf09e6785c4f3ab097bbafa6147d", size = 4940692, upload-time = "2026-05-19T11:13:09.428Z" }, ] [[package]] From c24c354d2ab2c470aecb01d6d527d8fe2041eee3 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 19 May 2026 12:36:00 -0700 Subject: [PATCH 5/6] fix(payment): address github-code-quality bot review on PR #53 - Wrap the warn-once flag in a _WarnedFlags class so the symbol is referenced at module scope (was: bare module-level bool only mutated via `global`, which the lint pass flagged as unused at module scope). - Drop the per-test `import compose_rails as mod` indirection; reset via _WarnedFlags directly. Eliminates the mixed `import` / `from import` styles bot complaint on test_compose_rails.py. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/payment/compose_rails.py | 16 ++++++++++++---- tests/test_compose_rails.py | 6 ++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/agentscore_commerce/payment/compose_rails.py b/agentscore_commerce/payment/compose_rails.py index 61f9a44..edba7d1 100644 --- a/agentscore_commerce/payment/compose_rails.py +++ b/agentscore_commerce/payment/compose_rails.py @@ -20,10 +20,19 @@ from agentscore_commerce.payment.usdc import USDC _SOLANA_MAINNET_CAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" -_warned_stripe_below_minimum = False _logger = logging.getLogger(__name__) +class _WarnedFlags: + """Warn-once flags for helpers that shouldn't spam logs. + + Wrapped in a class instead of a bare module-level bool so the symbol is + referenced at module scope rather than mutated only via ``global``. + """ + + stripe_below_minimum: bool = False + + def build_mppx_compose_rails( *, amount_usd: str, @@ -104,9 +113,8 @@ def build_mppx_compose_rails( except (InvalidOperation, TypeError, ValueError): amount_decimal = None if amount_decimal is not None and amount_decimal < STRIPE_MIN_CHARGE_USD: - global _warned_stripe_below_minimum - if not _warned_stripe_below_minimum: - _warned_stripe_below_minimum = True + if not _WarnedFlags.stripe_below_minimum: + _WarnedFlags.stripe_below_minimum = True _logger.warning( "[build_mppx_compose_rails] Dropping stripe/charge rail: amount_usd=%s is below " "Stripe's $%s USD minimum. Stripe's fixed ~$0.30 fee makes sub-50-cent charges " diff --git a/tests/test_compose_rails.py b/tests/test_compose_rails.py index 478684d..fb53131 100644 --- a/tests/test_compose_rails.py +++ b/tests/test_compose_rails.py @@ -2,7 +2,7 @@ import pytest -from agentscore_commerce.payment.compose_rails import build_mppx_compose_rails +from agentscore_commerce.payment.compose_rails import _WarnedFlags, build_mppx_compose_rails def test_emits_single_tempo_intent_when_only_tempo_recipient() -> None: @@ -57,9 +57,7 @@ def test_raises_when_amount_unparseable_with_solana_rail() -> None: def test_auto_drops_stripe_when_amount_below_min(caplog: pytest.LogCaptureFixture) -> None: - import agentscore_commerce.payment.compose_rails as mod - - mod._warned_stripe_below_minimum = False # reset module-level warn-once flag + _WarnedFlags.stripe_below_minimum = False # reset module-level warn-once flag with caplog.at_level("WARNING"): rails = build_mppx_compose_rails(amount_usd="0.01", tempo_recipient="0xabc") assert all(r[0] != "stripe/charge" for r in rails) From 67ef609cfb041161552ea0adf39b39e8793b315e Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 19 May 2026 15:13:52 -0700 Subject: [PATCH 6/6] feat(payment): surface typed errors when pympp's verifier raises When a pympp rail's verify() throws (e.g. a Tempo RPC rejection with keychain validation failed: KeyNotFound), the canonical compose hook previously swallowed str(error) and returned a bare MppxComposeOutcome(status=402), losing the recovery signal. The agent saw the generic `payment_proof_invalid: regenerate` body and had no hint to drive the WebAuthn enrollment flow. - Add `failure_reason: str | None` to MppxComposeOutcome. Custom hooks can opt in by setting it; make_mppx_compose_hook now captures `str(error)` automatically on the except branch. - New `classify_mppx_failure(reason)` mirrors the node SDK: known patterns map to typed ClassifiedMppxFailure envelopes. First entry: Tempo keychain rejection -> 401 `tempo_key_not_registered` with recovery hints (run `tempo wallet login` or switch rail). - `_handle_mppx` runs the classifier when failure_reason is set; returns the typed envelope. Falls back to the generic `payment_proof_invalid` otherwise. pympp already preserves the inner error via re-raise (unlike node's mppx which swallows + wraps), so no AsyncLocalStorage / console interception needed - the exception lands directly in the compose hook's catch block. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/checkout.py | 32 +++++++++-- agentscore_commerce/checkout_hooks.py | 6 +- agentscore_commerce/payment/mppx_failures.py | 59 ++++++++++++++++++++ tests/test_checkout.py | 25 +++++++++ tests/test_mppx_failures.py | 39 +++++++++++++ 5 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 agentscore_commerce/payment/mppx_failures.py create mode 100644 tests/test_mppx_failures.py diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index 18c7188..4e5a58a 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -86,6 +86,7 @@ from agentscore_commerce.challenge.validation_error import build_validation_error from agentscore_commerce.errors import CheckoutValidationError from agentscore_commerce.payment.constants import STRIPE_MIN_CHARGE_USD +from agentscore_commerce.payment.mppx_failures import classify_mppx_failure from agentscore_commerce.payment.payment_header import has_mppx_header, has_x402_header from agentscore_commerce.payment.rail_spec import ( RecipientLike, @@ -490,6 +491,14 @@ class MppxComposeOutcome: headers without parsing the JSON body.""" raw: Any = None """The underlying pympp compose result for ``on_settled`` introspection.""" + failure_reason: str | None = None + """For ``status=402``: optional reason string captured from the swallowed + inner verifier exception (e.g. ``"keychain validation failed: KeyNotFound"`` + when a Tempo signer isn't enrolled). When set, ``_handle_mppx`` runs + :func:`classify_mppx_failure` and returns a typed envelope + (``tempo_key_not_registered``, etc.) instead of the generic + ``payment_proof_invalid``. Populated by :func:`make_mppx_compose_hook` when + pympp's verifier raises; custom hooks can set it explicitly to opt in.""" @dataclass @@ -1754,10 +1763,25 @@ async def _handle_mppx(self, ctx: CheckoutContext) -> CheckoutResult: ) return await self._build_success(ctx, outcome) # _handle_mppx is only invoked when an ``Authorization: Payment`` header - # was present, so a 402 here means mppx REJECTED the credential. Surface - # as 400 ``payment_proof_invalid`` (the canonical "regenerate the - # credential" denial), echoing mppx's fresh WWW-Authenticate so the - # agent's retry signs against the new directive id. + # was present, so a 402 here means mppx REJECTED the credential. Try to + # classify the swallowed inner error (e.g. Tempo ``KeyNotFound``) into a + # typed envelope agents can route on; fall back to the generic + # ``payment_proof_invalid`` regenerate hint otherwise. + classified = classify_mppx_failure(composed.failure_reason) + if classified is not None: + return CheckoutResult( + status=classified.status, + body=build_validation_error( + code=classified.code, + message=classified.message, + next_steps=classified.next_steps, + extra=classified.extra or None, + ), + headers=dict(composed.headers or {}), + reference_id=ctx.reference_id, + settled=False, + settle_phase="verify_failed", + ) return CheckoutResult( status=400, body=build_validation_error( diff --git a/agentscore_commerce/checkout_hooks.py b/agentscore_commerce/checkout_hooks.py index 915400d..febed95 100644 --- a/agentscore_commerce/checkout_hooks.py +++ b/agentscore_commerce/checkout_hooks.py @@ -59,8 +59,10 @@ async def hook(ctx: CheckoutContext) -> MppxComposeOutcome: amount_str = f"{ctx.pricing.amount_usd:.{decimals}f}" try: result = await mpp.charge(authorization=authorization, amount=amount_str) - except Exception: - return MppxComposeOutcome(status=402) + except Exception as error: + # Capture the inner reason so Checkout can classify it into a typed + # envelope (e.g., Tempo `KeyNotFound` -> `tempo_key_not_registered`). + return MppxComposeOutcome(status=402, failure_reason=str(error)) if not isinstance(result, tuple): to_www = getattr(result, "to_www_authenticate", None) diff --git a/agentscore_commerce/payment/mppx_failures.py b/agentscore_commerce/payment/mppx_failures.py new file mode 100644 index 0000000..eca407d --- /dev/null +++ b/agentscore_commerce/payment/mppx_failures.py @@ -0,0 +1,59 @@ +"""Classifier for known mppx verification-failure patterns. + +When a pympp rail's ``verify()`` throws, the inner error contains a signal +agents can recover from (e.g. the agent's wallet isn't enrolled with +Tempo's keychain). This module maps known failure-reason strings to typed +``ClassifiedMppxFailure`` envelopes so the merchant SDK can return them +instead of the generic ``payment_proof_invalid: regenerate`` body. + +Mirrors node-commerce ``src/payment/mppx_failures.ts``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class ClassifiedMppxFailure: + """Typed envelope a CLI like ``tempo request`` or ``agentscore-pay`` can pattern-match on.""" + + code: str + status: int + message: str + next_steps: dict[str, str] + extra: dict[str, Any] = field(default_factory=dict) + + +_TEMPO_KEY_NOT_REGISTERED = ClassifiedMppxFailure( + code="tempo_key_not_registered", + status=401, + message=("Tempo rejected the transaction: signer wallet is not registered with Tempo's keychain."), + next_steps={ + "action": "register_tempo_key", + "user_message": ( + "Your wallet is not enrolled with Tempo. Run `tempo wallet login` to " + "complete the one-time WebAuthn enrollment (or use `tempo request` " + "directly), then retry. To skip enrollment, switch to the Base or " + "Solana rail on this 402." + ), + }, + extra={"upstream_error": "KeyNotFound", "chain": "tempo"}, +) + + +def classify_mppx_failure(reason: str | None) -> ClassifiedMppxFailure | None: + """Classify a failure-reason string against known patterns. + + Returns ``None`` when unrecognized — callers fall back to the generic + ``payment_proof_invalid`` envelope. The reason argument may be the raw + ``Exception`` message, ``shortMessage`` from a viem-shaped error, or any + string carrying the upstream description. Substring match, case-insensitive. + """ + if not reason: + return None + lower = reason.lower() + if "keychain validation failed" in lower or "keynotfound" in lower: + return _TEMPO_KEY_NOT_REGISTERED + return None diff --git a/tests/test_checkout.py b/tests/test_checkout.py index e025365..bfa8010 100644 --- a/tests/test_checkout.py +++ b/tests/test_checkout.py @@ -460,6 +460,31 @@ async def test_compose_mppx_returns_402_on_settle_leg_rejects_credential() -> No assert result.settle_phase == "verify_failed" +@pytest.mark.asyncio +async def test_compose_mppx_402_with_keychain_failure_surfaces_tempo_key_not_registered() -> None: + """When the compose hook captures a Tempo keychain rejection in failure_reason, + Checkout returns 401 ``tempo_key_not_registered`` instead of the generic + ``payment_proof_invalid`` so ``tempo request`` / ``agentscore-pay`` can route the user + to the WebAuthn enrollment flow.""" + compose_mppx = AsyncMock( + return_value=MppxComposeOutcome( + status=402, + headers={"www-authenticate": 'Payment id="ord_x"'}, + failure_reason="RPC Request failed. (keychain validation failed: AccountKeychainError(KeyNotFound(KeyNotFound)))", + ), + ) + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0), + compose_mppx=compose_mppx, + ) + result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"})) + assert result.status == 401 + assert result.body["error"]["code"] == "tempo_key_not_registered" + assert result.settle_phase == "verify_failed" + + @pytest.mark.asyncio async def test_compose_mppx_on_discovery_leg_layers_challenge_in_402() -> None: """On the discovery leg (no Authorization header), Checkout calls compose_mppx diff --git a/tests/test_mppx_failures.py b/tests/test_mppx_failures.py new file mode 100644 index 0000000..763ac72 --- /dev/null +++ b/tests/test_mppx_failures.py @@ -0,0 +1,39 @@ +"""Tests for ``agentscore_commerce.payment.mppx_failures``.""" + +from agentscore_commerce.payment.mppx_failures import classify_mppx_failure + + +def test_returns_none_when_reason_is_falsy() -> None: + assert classify_mppx_failure(None) is None + assert classify_mppx_failure("") is None + + +def test_returns_none_for_unrecognized_reasons() -> None: + assert classify_mppx_failure("insufficient funds") is None + assert classify_mppx_failure("Transaction reverted: ERC20") is None + + +def test_classifies_tempo_keychain_rejection_by_literal_pattern() -> None: + out = classify_mppx_failure( + "RPC Request failed. (keychain validation failed: AccountKeychainError(KeyNotFound(KeyNotFound)))" + ) + assert out is not None + assert out.code == "tempo_key_not_registered" + assert out.status == 401 + assert out.next_steps["action"] == "register_tempo_key" + assert out.extra["upstream_error"] == "KeyNotFound" + assert out.extra["chain"] == "tempo" + + +def test_matches_keynotfound_case_insensitively() -> None: + out = classify_mppx_failure("Some shorter message containing KeyNotFound somewhere") + assert out is not None + assert out.code == "tempo_key_not_registered" + + +def test_user_message_names_both_recovery_paths() -> None: + out = classify_mppx_failure("keychain validation failed: KeyNotFound") + assert out is not None + msg = out.next_steps["user_message"] + assert "tempo wallet login" in msg + assert "Base" in msg or "Solana" in msg