From 7b62509c72cdf69d7c8f3ba148c660308895c3b3 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 5 May 2026 06:08:58 -0700 Subject: [PATCH 1/4] fix(x402): call real x402 2.9 API + auto-coerce dict resource_config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in process_x402_settle uncovered by store's first real x402-base mainnet smoke today: 1. ``server.process_payment_request(payload, resource_config, resource_meta, extensions)`` is a fictional method. ``x402.x402ResourceServer`` actually exposes ``verify_payment(payload, requirements) -> VerifyResponse``. The call only worked against the in-tree ``_FakeServer`` stub which coincidentally implemented the made-up name. Real consumers got ``AttributeError("'x402ResourceServer' object has no attribute 'process_payment_request'")`` — surfaced as a generic facilitator_error with no useful detail. 2. ``server.build_payment_requirements(config)`` accesses ``config.network`` etc. as attributes. Consumers ported from the JS / Hono stack pass plain dicts with camelCase keys (``payTo``, ``maxTimeoutSeconds``); the dict raised ``AttributeError("'dict' object has no attribute 'network'")`` at the build step, before the verify ever ran. Fix: - Auto-coerce dict resource_config → ``x402.schemas.config.ResourceConfig`` with camelCase → snake_case alias mapping (``payTo`` → ``pay_to``, ``maxTimeoutSeconds`` → ``max_timeout_seconds``). Falls back to the input unchanged when the x402 peer dep is missing or validation fails. - Replace the fictional ``server.process_payment_request(...)`` with the real ``server.verify_payment(payload, matched_requirement)``. - Accept either ``is_valid`` (x402 2.9's VerifyResponse field) or ``success`` (older / stub shape) as the verify-result success signal. - Rename the ``step`` literal ``"process_payment_request"`` → ``"verify_payment"`` to match the real API. - Re-run ``build_payment_requirements`` after enrich_extensions to fold the enriched extension list into the build (x402 2.9 takes extensions at build time, not as a separate verify input). - Drop the un-awaited sync calls' ``await`` — ``build_payment_requirements`` and ``enrich_extensions`` are sync on x402 2.9. Tests: - ``_FakeServer`` updated to match the real surface (sync build/enrich, async verify_payment/settle_payment with the 2-arg signatures). - The wrap-test was renamed and its step assertion updated. - All 35 ``test_lifted_helpers.py`` tests pass; total suite 715 passed with coverage 95.41% above the 95% bar. Verified against the locally-installed ``x402==2.9.0`` via runtime ``inspect.signature(...)`` to confirm method names + signatures match. Bumps to 1.3.1. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/payment/x402_settle.py | 130 +++++++++++++++------ pyproject.toml | 2 +- tests/test_lifted_helpers.py | 14 ++- 3 files changed, 106 insertions(+), 40 deletions(-) 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..c973987 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 @@ -496,7 +502,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 +517,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) From 0c19b6e2e3ca9e7e96e750d529a63ffdf83b113d Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 5 May 2026 06:09:59 -0700 Subject: [PATCH 2/4] chore: regen uv.lock after 1.3.1 version bump Co-Authored-By: Claude Opus 4.7 (1M context) --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" }, From 2a319212fc311865c33e978f2f452d958748d1f4 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 5 May 2026 06:13:21 -0700 Subject: [PATCH 3/4] test: cover dict resource_config coercion in process_x402_settle Two tests for the new auto-coerce path: - camelCase dict (payTo, maxTimeoutSeconds) coerced into typed ResourceConfig - already-typed ResourceConfig passed through unchanged (`is`, not `==`) Locks the consumer contract so a future regression in _coerce_resource_config fails the test suite, not store-prod's first base settle. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_lifted_helpers.py | 79 ++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/test_lifted_helpers.py b/tests/test_lifted_helpers.py index c973987..e78d6a0 100644 --- a/tests/test_lifted_helpers.py +++ b/tests/test_lifted_helpers.py @@ -455,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 # ───────────────────────────────────────────────────────────────────────────── From 3559002ea9f325da7c3dee84b6d34cc2d4c3cc02 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 5 May 2026 06:17:17 -0700 Subject: [PATCH 4/4] docs(claude): note process_x402_settle calls real x402 2.9 verify_payment + dict-coerce Quick CLAUDE.md update so future readers don't have to re-derive the API surface of process_x402_settle (real method names + the camelCase dict coercion). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 |