diff --git a/agentscore_commerce/payment/__init__.py b/agentscore_commerce/payment/__init__.py index bb791e0..d5ab506 100644 --- a/agentscore_commerce/payment/__init__.py +++ b/agentscore_commerce/payment/__init__.py @@ -76,6 +76,7 @@ coerce_resource_config, process_x402_settle, settle_result_to_json_bytes, + strip_unsigned_x402_payload_fields, ) from agentscore_commerce.payment.x402_validation import ( X402_SUPPORTED_BASE_NETWORKS, @@ -164,6 +165,7 @@ "resolve_recipient", "settle_result_to_json_bytes", "settlement_override_header", + "strip_unsigned_x402_payload_fields", "usd_to_atomic", "validate_x402_network_config", "verify_x402_request", diff --git a/agentscore_commerce/payment/x402_settle.py b/agentscore_commerce/payment/x402_settle.py index 9dfd545..e020389 100644 --- a/agentscore_commerce/payment/x402_settle.py +++ b/agentscore_commerce/payment/x402_settle.py @@ -301,6 +301,31 @@ def coerce_payment_payload(payload: Any) -> Any: return payload +def strip_unsigned_x402_payload_fields(payload: Any) -> Any: + """Drop the non-signed ``extensions`` + ``resource`` blocks from a decoded X-Payment payload. + + x402 clients echo the 402 challenge's ``extensions`` (Bazaar input schema) and ``resource`` + into the payload alongside the signed ``payload`` + ``accepted``. Neither is part of the + EIP-3009 signature (which covers only ``payload.authorization``). The Coinbase facilitator's + ``/x402/verify`` validates the payment payload against its ``x402V2PaymentPayload`` schema, + which is ``{x402Version, payload, accepted}`` and admits neither ``extensions`` nor + ``resource``: their presence makes the payload match no union branch and CDP rejects it + (``must match one of [x402V2PaymentPayload, x402V1PaymentPayload]``). Routes whose echoed + Bazaar schema is large fail while small ones slip through, so it presents as route-dependent + but is one shape bug. + + ``accepted`` (which ``verify_x402_request`` reads for network/payTo, and which CDP requires) + and every other field stay intact; only the two echoed challenge blocks are dropped, before + the payload is coerced to the typed model whose ``model_dump`` reaches the facilitator. + Non-dict payloads, and payloads carrying neither field, pass through unchanged. + """ + if not isinstance(payload, dict): + return payload + if "extensions" not in payload and "resource" not in payload: + return payload + return {k: v for k, v in payload.items() if k not in ("extensions", "resource")} + + async def process_x402_settle( *, x402_server: Any, @@ -322,7 +347,9 @@ async def process_x402_settle( """ server = x402_server coerced_config = coerce_resource_config(resource_config) - coerced_payload = coerce_payment_payload(payload) + # Drop the echoed extensions/resource blocks before coercion: CDP's verify schema admits + # neither, so their presence makes the payload match no union branch and settle fails. + coerced_payload = coerce_payment_payload(strip_unsigned_x402_payload_fields(payload)) try: built_requirements = server.build_payment_requirements(coerced_config) diff --git a/pyproject.toml b/pyproject.toml index ae2be50..13d727a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "2.5.9" +version = "2.5.10" 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_x402_settle_extra.py b/tests/test_x402_settle_extra.py index 06b41da..56fe46a 100644 --- a/tests/test_x402_settle_extra.py +++ b/tests/test_x402_settle_extra.py @@ -11,6 +11,7 @@ classify_x402_settle_result, process_x402_settle, settle_result_to_json_bytes, + strip_unsigned_x402_payload_fields, ) @@ -123,3 +124,55 @@ def test_settle_result_to_json_bytes() -> None: out = settle_result_to_json_bytes({"a": 1, "b": "two"}) assert isinstance(out, bytes) assert b'"a"' in out + + +_ACCEPTED = {"scheme": "exact", "network": "eip155:8453", "payTo": "0xabc"} +_INNER = {"authorization": {"from": "0xsigner", "to": "0xabc"}, "signature": "0xdeadbeef"} +_WIRE = { + "x402Version": 2, + "payload": _INNER, + "accepted": _ACCEPTED, + "extensions": {"bazaar": {"schema": {"type": "object", "properties": {"phone": {"type": "string"}}}}}, + "resource": {"url": "https://x/person/base/no-pii", "description": "d" * 600, "tags": ["person"]}, +} + + +def test_strip_drops_extensions_and_resource_keeps_accepted() -> None: + assert strip_unsigned_x402_payload_fields(_WIRE) == { + "x402Version": 2, + "payload": _INNER, + "accepted": _ACCEPTED, + } + + +def test_strip_returns_same_object_when_neither_field_present() -> None: + lean = {"x402Version": 2, "payload": _INNER, "accepted": _ACCEPTED} + assert strip_unsigned_x402_payload_fields(lean) is lean + + +def test_strip_handles_single_field_and_non_dict() -> None: + assert strip_unsigned_x402_payload_fields({"payload": _INNER, "resource": {"url": "x"}}) == {"payload": _INNER} + assert strip_unsigned_x402_payload_fields({"payload": _INNER, "extensions": {"bazaar": {}}}) == {"payload": _INNER} + assert strip_unsigned_x402_payload_fields(None) is None + assert strip_unsigned_x402_payload_fields("not-a-dict") == "not-a-dict" + + +@pytest.mark.asyncio +async def test_process_x402_settle_strips_bloat_before_facilitator() -> None: + # Incomplete `accepted` makes coerce_payment_payload leave the payload a plain dict, so we + # can assert directly on what reaches verify_payment / settle_payment. The strip must have + # removed extensions/resource and kept accepted (which CDP's v2 schema requires). + server = _make_server() + result = await process_x402_settle( + x402_server=server, + payload=_WIRE, + resource_config={"scheme": "exact", "network": "eip155:8453", "payTo": "0xabc"}, + resource_meta={"url": "https://x/person/base/no-pii", "description": "t", "mimeType": "application/json"}, + ) + assert isinstance(result, ProcessX402SettleSuccess) + for mock in (server.verify_payment, server.settle_payment): + forwarded = mock.call_args[0][0] + assert forwarded == {"x402Version": 2, "payload": _INNER, "accepted": _ACCEPTED} + assert "accepted" in forwarded + assert "extensions" not in forwarded + assert "resource" not in forwarded diff --git a/uv.lock b/uv.lock index 785c0b7..1165035 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ constraints = [{ name = "fastapi", specifier = "!=0.136.3" }] [[package]] name = "agentscore-commerce" -version = "2.5.9" +version = "2.5.10" source = { editable = "." } dependencies = [ { name = "agentscore-py" },