diff --git a/agentscore_commerce/payment/x402_settle.py b/agentscore_commerce/payment/x402_settle.py index 7facdc0..bdc6b0d 100644 --- a/agentscore_commerce/payment/x402_settle.py +++ b/agentscore_commerce/payment/x402_settle.py @@ -220,10 +220,40 @@ def _coerce_resource_config(config: Any) -> Any: return config +def _coerce_payment_payload(payload: Any) -> Any: + """Best-effort dict → x402 ``PaymentPayload`` (v1 or v2) coercion. + + ``verify_x402_request`` returns ``payload`` as a plain dict (the result of + ``json.loads(base64.b64decode(X-Payment))``), but x402 2.9's + ``server.verify_payment`` / ``server.settle_payment`` call ``payload.get_scheme()`` + and other typed-model methods on it. Without coercion, the dict raises + ``AttributeError("'dict' object has no attribute 'get_scheme'")`` on the verify leg. + + Routes by the ``x402Version`` field: ``1`` → ``PaymentPayloadV1`` (flat shape with + top-level ``scheme`` / ``network``); anything else → ``PaymentPayload`` (v2 shape + nested under ``accepted``). Falls back to the original dict on any failure so callers + that already pass typed instances or unusual shapes still flow through unchanged. + """ + if not isinstance(payload, dict): + return payload + try: + from x402.schemas import PaymentPayload + from x402.schemas.v1 import PaymentPayloadV1 + except ImportError: + return payload + version = payload.get("x402Version") + model = PaymentPayloadV1 if version == 1 else PaymentPayload + try: + return model.model_validate(payload) + except Exception: + return payload + + 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) + payload = _coerce_payment_payload(input.payload) try: built_requirements = server.build_payment_requirements(resource_config) @@ -267,7 +297,7 @@ async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402Settl # — not ``process_payment_request`` (a fictional method that earlier versions of this # helper called and only ever worked against test stubs). try: - verify_result = await server.verify_payment(input.payload, matched_requirement) + verify_result = await server.verify_payment(payload, matched_requirement) except Exception as err: return ProcessX402SettleFailure(phase="facilitator_error", step="verify_payment", error=err) @@ -288,11 +318,10 @@ async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402Settl return ProcessX402SettleFailure(phase="verify_failed", verify_result=verify_result) try: - settle_result = await server.settle_payment(input.payload, matched_requirement) + settle_result = await server.settle_payment(payload, matched_requirement) payment_response_header: str | None = None if settle_result is not None: - payload_bytes = json.dumps(settle_result, separators=(",", ":")).encode() - payment_response_header = base64.b64encode(payload_bytes).decode() + payment_response_header = base64.b64encode(_settle_result_to_json_bytes(settle_result)).decode() return ProcessX402SettleSuccess( matched_requirement=matched_requirement, settle_result=settle_result, @@ -301,3 +330,19 @@ async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402Settl ) except Exception as err: return ProcessX402SettleFailure(phase="settle_failed", error=err, matched_requirement=matched_requirement) + + +def _settle_result_to_json_bytes(settle_result: Any) -> bytes: + """Serialize the settle result to a base64-friendly JSON byte string. + + x402 2.9's ``settle_payment`` returns a Pydantic ``SettleResponse`` model that + ``json.dumps`` rejects with ``TypeError: Object of type SettleResponse is not + JSON serializable``. Use ``model_dump_json(by_alias=True)`` for Pydantic models + (so emitted keys match the wire shape — ``errorReason`` / ``errorMessage`` rather + than the snake_case attrs) and fall through to ``json.dumps`` for plain dicts + (used by older x402 / test stubs). + """ + model_dump_json = getattr(settle_result, "model_dump_json", None) + if callable(model_dump_json): + return model_dump_json(by_alias=True).encode() + return json.dumps(settle_result, separators=(",", ":")).encode() diff --git a/pyproject.toml b/pyproject.toml index 3c63d93..d124a25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "1.3.2" +version = "1.3.3" 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 e78d6a0..7e17203 100644 --- a/tests/test_lifted_helpers.py +++ b/tests/test_lifted_helpers.py @@ -534,6 +534,220 @@ async def settle_payment(self, _payload: object, _req: object) -> dict: assert captured["cfg"] is typed +@pytest.mark.asyncio +async def test_process_x402_settle_coerces_dict_payload_v2_to_typed_payment_payload(): + """Same fix as resource_config: ``verify_x402_request`` returns ``payload`` as a + plain dict (the result of ``json.loads(base64.b64decode(X-Payment))``); x402 2.9's + ``server.verify_payment`` calls ``payload.get_scheme()`` and other typed-model methods + on it. Coerce dicts → ``PaymentPayload`` (or ``PaymentPayloadV1`` when ``x402Version=1``) + so the helper doesn't ``AttributeError`` at the verify leg. + """ + captured: dict = {} + + class _CapturingServer: + def build_payment_requirements(self, _cfg: object, _ext: object = None) -> list: + return [{"id": "req1"}] + + async def verify_payment(self, payload: object, _req: object) -> dict: + captured["verify_payload"] = payload + return {"is_valid": True} + + async def settle_payment(self, payload: object, _req: object) -> dict: + captured["settle_payload"] = payload + return {"tx_hash": "0xabc"} + + payload_dict = { + "x402Version": 2, + "accepted": { + "scheme": "exact", + "network": "eip155:8453", + "amount": "100000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": "0x000000000000000000000000000000000000dEaD", + "maxTimeoutSeconds": 300, + }, + "payload": { + "authorization": { + "from": "0xeb2Ca790F72787c7e61bC6c861353a1e4ACDFCa5", + "to": "0x000000000000000000000000000000000000dEaD", + "value": "100000", + "validAfter": 0, + "validBefore": 9999999999, + "nonce": "0x" + "aa" * 32, + }, + "signature": "0x" + "cc" * 65, + }, + } + res = await process_x402_settle( + ProcessX402SettleInput( + x402_server=_CapturingServer(), + payload=payload_dict, + resource_config={ + "scheme": "exact", + "network": "eip155:8453", + "price": "$0.10", + "payTo": "0x" + "00" * 19 + "dE" + "aD", + "maxTimeoutSeconds": 300, + }, + resource_meta=_RESOURCE_META, + ) + ) + assert isinstance(res, ProcessX402SettleSuccess) + # Both verify and settle legs received the typed Pydantic model, not the raw dict. + verify_payload = captured["verify_payload"] + settle_payload = captured["settle_payload"] + assert type(verify_payload).__name__ == "PaymentPayload" + assert type(settle_payload).__name__ == "PaymentPayload" + # The typed model exposes the get_scheme() method that x402's facilitator calls. + assert hasattr(verify_payload, "get_scheme") + assert verify_payload.get_scheme() == "exact" + + +@pytest.mark.asyncio +async def test_process_x402_settle_serializes_pydantic_settle_result_to_payment_response_header(): + """x402 2.9's ``settle_payment`` returns a Pydantic ``SettleResponse`` model; + plain ``json.dumps`` rejects it with ``TypeError``. The helper must call + ``model_dump_json(by_alias=True)`` so the X-Payment-Response header stays + base64'd JSON with the right wire keys (``errorReason`` not ``error_reason``). + """ + from x402.schemas import SettleResponse + + pydantic_settle = SettleResponse( + success=True, + transaction="0xabc", + network="eip155:8453", + payer="0x000000000000000000000000000000000000dEaD", + amount=None, + ) + + class _PydanticSettleServer: + def build_payment_requirements(self, _cfg: object, _ext: object = None) -> list: + 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) -> SettleResponse: + return pydantic_settle + + res = await process_x402_settle( + ProcessX402SettleInput( + x402_server=_PydanticSettleServer(), + payload={}, + resource_config={ + "scheme": "exact", + "network": "eip155:8453", + "price": "$0.10", + "payTo": "0x" + "00" * 19 + "dE" + "aD", + "maxTimeoutSeconds": 300, + }, + resource_meta=_RESOURCE_META, + ) + ) + assert isinstance(res, ProcessX402SettleSuccess) + assert res.payment_response_header is not None + decoded = json.loads(base64.b64decode(res.payment_response_header).decode()) + assert decoded["success"] is True + assert decoded["transaction"] == "0xabc" + assert decoded["network"] == "eip155:8453" + # by_alias=True: wire shape uses errorReason / errorMessage (camelCase) — not snake_case. + assert "errorReason" in decoded + + +@pytest.mark.asyncio +async def test_process_x402_settle_serializes_dict_settle_result_for_legacy_stubs(): + """Plain-dict settle results (from older x402 / test stubs) still serialize.""" + + class _DictSettleServer: + def build_payment_requirements(self, _cfg: object, _ext: object = None) -> list: + 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 {"success": True, "transaction": "0xdef"} + + res = await process_x402_settle( + ProcessX402SettleInput( + x402_server=_DictSettleServer(), + payload={}, + resource_config={ + "scheme": "exact", + "network": "eip155:8453", + "price": "$0.10", + "payTo": "0x" + "00" * 19 + "dE" + "aD", + "maxTimeoutSeconds": 300, + }, + resource_meta=_RESOURCE_META, + ) + ) + assert isinstance(res, ProcessX402SettleSuccess) + assert res.payment_response_header is not None + decoded = json.loads(base64.b64decode(res.payment_response_header).decode()) + assert decoded == {"success": True, "transaction": "0xdef"} + + +@pytest.mark.asyncio +async def test_process_x402_settle_passes_typed_payment_payload_unchanged(): + """If the caller already provides a typed PaymentPayload, pass it through unchanged.""" + from x402.schemas import PaymentPayload + + typed = PaymentPayload.model_validate( + { + "x402Version": 2, + "accepted": { + "scheme": "exact", + "network": "eip155:8453", + "amount": "100000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": "0x000000000000000000000000000000000000dEaD", + "maxTimeoutSeconds": 300, + }, + "payload": { + "authorization": { + "from": "0xeb2Ca790F72787c7e61bC6c861353a1e4ACDFCa5", + "to": "0x000000000000000000000000000000000000dEaD", + "value": "100000", + "validAfter": 0, + "validBefore": 9999999999, + "nonce": "0x" + "aa" * 32, + }, + "signature": "0x" + "cc" * 65, + }, + } + ) + captured: dict = {} + + class _CapturingServer: + def build_payment_requirements(self, _cfg: object, _ext: object = None) -> list: + return [{"id": "req1"}] + + async def verify_payment(self, payload: object, _req: object) -> dict: + captured["payload"] = payload + 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=typed, + resource_config={ + "scheme": "exact", + "network": "eip155:8453", + "price": "$0.10", + "payTo": "0x" + "00" * 19 + "dE" + "aD", + "maxTimeoutSeconds": 300, + }, + resource_meta=_RESOURCE_META, + ) + ) + assert isinstance(res, ProcessX402SettleSuccess) + assert captured["payload"] is typed + + # ───────────────────────────────────────────────────────────────────────────── # process_x402_settle: facilitator_error wrap # ───────────────────────────────────────────────────────────────────────────── diff --git a/uv.lock b/uv.lock index f61d33d..00b5162 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [[package]] name = "agentscore-commerce" -version = "1.3.2" +version = "1.3.3" source = { editable = "." } dependencies = [ { name = "agentscore-py" },