diff --git a/CLAUDE.md b/CLAUDE.md index c747512..2abd56e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Every helper is extracted from a real consumer, not speculated. | Submodule | What it is | |---|---| | `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware (KYC, age, sanctions, jurisdiction) | -| `agentscore_commerce.payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `create_x402_server` (wraps official `x402[evm]>=2.8` peer dep with v1+v2 dual-register + bazaar extension), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6` peer dep with Tempo charge/session + Stripe SPT helpers), 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 official `x402[evm]>=2.8` peer dep with v1+v2 dual-register + bazaar extension), `process_x402_settle` (single-call verify+settle wrapper around `x402ResourceServer`'s real 2.9 API: `build_payment_requirements` → `verify_payment` → `settle_payment`; auto-coerces dict `resource_config` with camelCase keys → typed `ResourceConfig`), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6` peer dep with Tempo charge/session + Stripe SPT helpers), 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 | diff --git a/agentscore_commerce/payment/x402_settle.py b/agentscore_commerce/payment/x402_settle.py index dc400fb..7facdc0 100644 --- a/agentscore_commerce/payment/x402_settle.py +++ b/agentscore_commerce/payment/x402_settle.py @@ -1,15 +1,20 @@ """``process_x402_settle``: single-call x402 verify+settle for merchants. -Wraps the four x402-server steps every x402-accepting merchant repeats: +Wraps the four x402-server steps every x402-accepting merchant repeats against +``x402.x402ResourceServer`` (sync ``build_payment_requirements`` + sync +``enrich_extensions`` + async ``verify_payment`` + async ``settle_payment``): 1. ``build_payment_requirements(resource_config)``: builds the requirement entries the facilitator validates against -2. ``enrich_extensions(extension, transport_context)``: folds in Bazaar (or other) - extensions for the verify step -3. ``process_payment_request(payload, resource_config, resource_meta, extensions)``: - runs verify against the facilitator +2. ``enrich_extensions(declared, transport_context)``: folds in Bazaar (or other) + extensions for the verify step (only when ``input.extension`` is supplied) +3. ``verify_payment(payload, matched_requirement)``: runs verify against the facilitator 4. ``settle_payment(payload, matched_requirement)``: settles on-chain +Accepts ``resource_config`` as either a ``dict`` (JS-style with ``payTo`` / +``maxTimeoutSeconds`` camelCase keys) or an ``x402.schemas.config.ResourceConfig``; +dicts are coerced before the build step so callers don't have to import the x402 type. + Returns a tagged result so the caller can map errors to merchant-shaped responses without owning the orchestration boilerplate. Use :func:`classify_x402_settle_result` to map the tagged result to a recommended HTTP response. @@ -72,10 +77,10 @@ class ProcessX402SettleFailure: ``error`` server-side; map to a controlled 503 with ``payment_provider_unavailable``. - ``facilitator_error``: facilitator raised during one of the verify-stage calls - (build requirements, extension enrich, or process_payment_request). Most common - cause: facilitator client rejects the configured network. Log raw ``error`` - server-side; map to a controlled 503 so the agent can pick a different rail. - ``step`` indicates which verify-stage call raised. + (build requirements, extension enrich, or verify_payment). Most common cause: + facilitator client rejects the configured network. Log raw ``error`` server-side; + map to a controlled 503 so the agent can pick a different rail. ``step`` indicates + which verify-stage call raised. """ phase: Literal["no_requirements", "verify_failed", "settle_failed", "facilitator_error"] @@ -85,8 +90,8 @@ class ProcessX402SettleFailure: error: Any = None matched_requirement: Any = None #: Populated only when ``phase == "facilitator_error"``. Indicates which verify-stage - #: call raised: ``"build_requirements"`` / ``"enrich_extensions"`` / ``"process_payment_request"``. - step: Literal["build_requirements", "enrich_extensions", "process_payment_request"] | None = None + #: call raised: ``"build_requirements"`` / ``"enrich_extensions"`` / ``"verify_payment"``. + step: Literal["build_requirements", "enrich_extensions", "verify_payment"] | None = None extra: dict[str, Any] = field(default_factory=dict) @@ -184,12 +189,44 @@ def classify_x402_settle_result(result: ProcessX402SettleResult) -> ClassifiedX4 return None +def _coerce_resource_config(config: Any) -> Any: + """Best-effort dict → x402 ``ResourceConfig`` coercion. + + Consumers ported from the JS / Hono stack often pass a plain dict with the JS-style + ``payTo`` / ``maxTimeoutSeconds`` camelCase keys (the shape that ``processX402Settle`` + in node-commerce accepts). x402's Python ``ResourceConfig`` is a Pydantic model with + ``pay_to`` / ``max_timeout_seconds`` snake_case fields, and ``build_payment_requirements`` + does ``config.network`` attribute access — so a raw dict raises + ``AttributeError("'dict' object has no attribute 'network'")``. Coerce here so callers + can pass either shape. + + Falls back to the original input on any failure (missing peer dep, validation error) + so caller-side typed instances still pass through unchanged. + """ + if not isinstance(config, dict): + return config + try: + from x402.schemas.config import ResourceConfig + except ImportError: + return config + coerced = dict(config) + if "payTo" in coerced and "pay_to" not in coerced: + coerced["pay_to"] = coerced.pop("payTo") + if "maxTimeoutSeconds" in coerced and "max_timeout_seconds" not in coerced: + coerced["max_timeout_seconds"] = coerced.pop("maxTimeoutSeconds") + try: + return ResourceConfig(**coerced) + except Exception: + return config + + async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402SettleResult: """Run the x402 verify→settle flow and return a tagged outcome.""" server = input.x402_server + resource_config = _coerce_resource_config(input.resource_config) try: - built_requirements = await server.build_payment_requirements(input.resource_config) + built_requirements = server.build_payment_requirements(resource_config) except Exception as err: return ProcessX402SettleFailure(phase="facilitator_error", step="build_requirements", error=err) if not built_requirements: @@ -199,32 +236,55 @@ async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402Settl ) matched_requirement = built_requirements[0] - transport_context = input.transport_context - if transport_context is None: - path = urlparse(input.resource_meta["url"]).path - transport_context = { - "method": "POST", - "adapter": {"getPath": lambda: path}, - "routePattern": path, - } - + # Per-request extension enrichment runs only when a caller explicitly attaches one + # (e.g. the Bazaar discovery extension). x402 2.9 takes the enriched dict as the + # second argument to ``build_payment_requirements`` rather than as a verify-step + # input, but the fold happens at build time — so we replay the build with the + # enriched extensions and use those requirements going forward. + if input.extension is not None: + transport_context = input.transport_context + if transport_context is None: + path = urlparse(input.resource_meta["url"]).path + transport_context = { + "method": "POST", + "adapter": {"getPath": lambda: path}, + "routePattern": path, + } + try: + enriched_ext = server.enrich_extensions(input.extension, transport_context) + except Exception as err: + return ProcessX402SettleFailure(phase="facilitator_error", step="enrich_extensions", error=err) + try: + built_requirements = server.build_payment_requirements( + resource_config, list(enriched_ext.keys()) if isinstance(enriched_ext, dict) else None + ) + if built_requirements: + matched_requirement = built_requirements[0] + except Exception as err: + return ProcessX402SettleFailure(phase="facilitator_error", step="build_requirements", error=err) + + # x402 2.9's ``x402ResourceServer`` exposes ``verify_payment(payload, requirements)`` + # — not ``process_payment_request`` (a fictional method that earlier versions of this + # helper called and only ever worked against test stubs). try: - enriched_ext = ( - server.enrich_extensions(input.extension, transport_context) if input.extension is not None else None - ) + verify_result = await server.verify_payment(input.payload, matched_requirement) except Exception as err: - return ProcessX402SettleFailure(phase="facilitator_error", step="enrich_extensions", error=err) - - try: - verify_result = await server.process_payment_request( - input.payload, input.resource_config, input.resource_meta, enriched_ext + return ProcessX402SettleFailure(phase="facilitator_error", step="verify_payment", error=err) + + # x402's VerifyResponse exposes ``is_valid``; some stubs / older facilitators expose + # ``success``. Accept either. + is_valid = ( + getattr(verify_result, "is_valid", None) + if hasattr(verify_result, "is_valid") + else (verify_result.get("is_valid") if isinstance(verify_result, dict) else None) + ) + if is_valid is None: + is_valid = ( + getattr(verify_result, "success", None) + if hasattr(verify_result, "success") + else (verify_result.get("success") if isinstance(verify_result, dict) else False) ) - except Exception as err: - return ProcessX402SettleFailure(phase="facilitator_error", step="process_payment_request", error=err) - - if not getattr( - verify_result, "success", verify_result.get("success") if isinstance(verify_result, dict) else False - ): + if not is_valid: return ProcessX402SettleFailure(phase="verify_failed", verify_result=verify_result) try: diff --git a/pyproject.toml b/pyproject.toml index cac872d..696e139 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "1.3.0" +version = "1.3.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_lifted_helpers.py b/tests/test_lifted_helpers.py index c93246a..e78d6a0 100644 --- a/tests/test_lifted_helpers.py +++ b/tests/test_lifted_helpers.py @@ -339,6 +339,12 @@ async def test_verify_x402_rejects_solana_credential(): class _FakeServer: + """Stubbed x402 server matching the x402 2.9 ``x402ResourceServer`` surface: + sync ``build_payment_requirements(config, extensions=None)`` + sync + ``enrich_extensions(declared, transport_context)`` + async ``verify_payment(payload, + requirements)`` + async ``settle_payment(payload, requirements)``. + """ + def __init__( self, requirements: list | Exception, @@ -351,7 +357,7 @@ def __init__( self.settle_result = settle_result self.enrich_result = enrich_result - async def build_payment_requirements(self, _cfg: object) -> list: + def build_payment_requirements(self, _cfg: object, _extensions: object = None) -> list: if isinstance(self.requirements, Exception): raise self.requirements return self.requirements @@ -361,7 +367,7 @@ def enrich_extensions(self, ext: object, _ctx: object) -> object: raise self.enrich_result return ext if self.enrich_result == "passthrough" else self.enrich_result - async def process_payment_request(self, _payload: object, _cfg: object, _meta: object, _ext: object) -> dict: + async def verify_payment(self, _payload: object, _req: object) -> dict: if isinstance(self.verify_result, Exception): raise self.verify_result return self.verify_result @@ -449,6 +455,85 @@ async def test_process_x402_settle_success_returns_payment_response_header(): assert decoded["tx_hash"] == "0xabc" +@pytest.mark.asyncio +async def test_process_x402_settle_coerces_dict_resource_config_with_camelcase_keys(): + """Consumers ported from the JS / Hono stack pass dicts with camelCase keys + (``payTo``, ``maxTimeoutSeconds``). Coerce them into x402's ResourceConfig so + ``build_payment_requirements(config.network)`` doesn't ``AttributeError`` on a dict. + """ + captured: dict = {} + + class _CapturingServer: + def build_payment_requirements(self, cfg: object, _ext: object = None) -> list: + captured["cfg"] = cfg + return [{"id": "req1"}] + + async def verify_payment(self, _payload: object, _req: object) -> dict: + return {"is_valid": True} + + async def settle_payment(self, _payload: object, _req: object) -> dict: + return {"tx_hash": "0xabc"} + + res = await process_x402_settle( + ProcessX402SettleInput( + x402_server=_CapturingServer(), + payload={}, + resource_config={ + "scheme": "exact", + "network": "eip155:8453", + "price": "$0.10", + "payTo": "0xa43d4e316ef5f430426cd1b454167e5f85e3f4f1", + "maxTimeoutSeconds": 300, + }, + resource_meta=_RESOURCE_META, + ) + ) + assert isinstance(res, ProcessX402SettleSuccess) + # Coerced into x402's ResourceConfig (Pydantic model with snake_case attrs). + cfg = captured["cfg"] + assert hasattr(cfg, "network") + assert cfg.network == "eip155:8453" + assert cfg.pay_to == "0xa43d4e316ef5f430426cd1b454167e5f85e3f4f1" + assert cfg.max_timeout_seconds == 300 + + +@pytest.mark.asyncio +async def test_process_x402_settle_passes_typed_resource_config_unchanged(): + """If the caller already provides a typed ResourceConfig, pass it through unchanged.""" + from x402.schemas.config import ResourceConfig + + typed = ResourceConfig( + scheme="exact", + network="eip155:8453", + price="$0.10", + pay_to="0xa43d4e316ef5f430426cd1b454167e5f85e3f4f1", + max_timeout_seconds=300, + ) + captured: dict = {} + + class _CapturingServer: + def build_payment_requirements(self, cfg: object, _ext: object = None) -> list: + captured["cfg"] = cfg + return [{"id": "req1"}] + + async def verify_payment(self, _payload: object, _req: object) -> dict: + return {"is_valid": True} + + async def settle_payment(self, _payload: object, _req: object) -> dict: + return {"tx_hash": "0xabc"} + + res = await process_x402_settle( + ProcessX402SettleInput( + x402_server=_CapturingServer(), + payload={}, + resource_config=typed, + resource_meta=_RESOURCE_META, + ) + ) + assert isinstance(res, ProcessX402SettleSuccess) + assert captured["cfg"] is typed + + # ───────────────────────────────────────────────────────────────────────────── # process_x402_settle: facilitator_error wrap # ───────────────────────────────────────────────────────────────────────────── @@ -496,7 +581,7 @@ async def test_process_x402_settle_wraps_enrich_extensions_throws_as_facilitator @pytest.mark.asyncio -async def test_process_x402_settle_wraps_process_payment_request_throws_as_facilitator_error(): +async def test_process_x402_settle_wraps_verify_payment_throws_as_facilitator_error(): server = _FakeServer( requirements=[{"id": "req1"}], verify_result=RuntimeError("CDP facilitator: solana:devnet not supported"), @@ -511,7 +596,7 @@ async def test_process_x402_settle_wraps_process_payment_request_throws_as_facil ) assert isinstance(res, ProcessX402SettleFailure) assert res.phase == "facilitator_error" - assert res.step == "process_payment_request" + assert res.step == "verify_payment" assert isinstance(res.error, RuntimeError) diff --git a/uv.lock b/uv.lock index db82644..e656cce 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [[package]] name = "agentscore-commerce" -version = "1.3.0" +version = "1.3.1" source = { editable = "." } dependencies = [ { name = "agentscore-py" },